From 8a0c15ac1186a4b7d2e78cff828a942b59cb0449 Mon Sep 17 00:00:00 2001 From: Art Date: Thu, 23 Mar 2023 19:11:34 +0000 Subject: [PATCH] feat(explorer): breadcrumbs (#3254) --- apps/explorer-e2e/src/integration/home.cy.js | 95 +--- apps/explorer/src/app/app.tsx | 84 +--- .../src/app/components/header/header.spec.tsx | 3 +- .../src/app/components/header/header.tsx | 23 +- .../src/app/components/main/index.tsx | 9 - .../router-error-boundary/index.tsx | 32 -- .../components/search/detect-search.spec.ts | 223 +++------ .../app/components/search/detect-search.ts | 141 ++---- .../src/app/components/search/search.spec.tsx | 189 +++----- .../src/app/components/search/search.tsx | 245 ++++++---- apps/explorer/src/app/routes/home/index.tsx | 11 +- apps/explorer/src/app/routes/index.tsx | 26 -- apps/explorer/src/app/routes/layout.tsx | 187 ++++++++ .../explorer/src/app/routes/router-config.tsx | 278 ++++++++---- apps/explorer/src/app/routes/txs/id/index.tsx | 17 +- apps/explorer/src/main.tsx | 5 +- .../components/promoted-stats-item/index.ts | 1 - .../promoted-stats-item.tsx | 36 -- .../src/components/promoted-stats/index.ts | 1 - .../promoted-stats/promoted-stats.tsx | 13 - .../stats-manager/stats-manager.tsx | 156 +++---- .../src/components/table-row/index.ts | 1 - .../src/components/table-row/table-row.tsx | 41 -- .../src/components/table/index.ts | 1 - .../src/components/table/table.tsx | 13 - libs/network-stats/src/config/stats-fields.ts | 423 ++++++++++-------- libs/network-stats/src/config/types.ts | 26 -- .../background-video/background-video.tsx | 12 +- .../breadcrumbs/breadcrumbs-container.tsx | 20 + .../breadcrumbs/breadcrumbs.spec.tsx | 34 ++ .../breadcrumbs/breadcrumbs.stories.tsx | 19 + .../components/breadcrumbs/breadcrumbs.tsx | 35 ++ .../src/components/breadcrumbs/index.ts | 2 + libs/ui-toolkit/src/components/index.ts | 1 + package.json | 4 +- yarn.lock | 67 +-- 36 files changed, 1174 insertions(+), 1300 deletions(-) delete mode 100644 apps/explorer/src/app/components/main/index.tsx delete mode 100644 apps/explorer/src/app/components/router-error-boundary/index.tsx delete mode 100644 apps/explorer/src/app/routes/index.tsx create mode 100644 apps/explorer/src/app/routes/layout.tsx delete mode 100644 libs/network-stats/src/components/promoted-stats-item/index.ts delete mode 100644 libs/network-stats/src/components/promoted-stats-item/promoted-stats-item.tsx delete mode 100644 libs/network-stats/src/components/promoted-stats/index.ts delete mode 100644 libs/network-stats/src/components/promoted-stats/promoted-stats.tsx delete mode 100644 libs/network-stats/src/components/table-row/index.ts delete mode 100644 libs/network-stats/src/components/table-row/table-row.tsx delete mode 100644 libs/network-stats/src/components/table/index.ts delete mode 100644 libs/network-stats/src/components/table/table.tsx delete mode 100644 libs/network-stats/src/config/types.ts create mode 100644 libs/ui-toolkit/src/components/breadcrumbs/breadcrumbs-container.tsx create mode 100644 libs/ui-toolkit/src/components/breadcrumbs/breadcrumbs.spec.tsx create mode 100644 libs/ui-toolkit/src/components/breadcrumbs/breadcrumbs.stories.tsx create mode 100644 libs/ui-toolkit/src/components/breadcrumbs/breadcrumbs.tsx create mode 100644 libs/ui-toolkit/src/components/breadcrumbs/index.ts diff --git a/apps/explorer-e2e/src/integration/home.cy.js b/apps/explorer-e2e/src/integration/home.cy.js index beef6ea12..5f9342727 100644 --- a/apps/explorer-e2e/src/integration/home.cy.js +++ b/apps/explorer-e2e/src/integration/home.cy.js @@ -6,18 +6,10 @@ context('Home Page', function () { describe('Stats page', { tags: '@smoke' }, function () { const statsValue = '[data-testid="stats-value"]'; - it('Should show connected environment', function () { - const deployedEnv = Cypress.env('environment').toUpperCase(); - cy.get('[data-testid="stats-environment"]').should( - 'have.text', - `/ ${deployedEnv}` - ); - }); - it('should show connected environment stats', function () { const statTitles = { 0: 'Status', - 1: 'Height', + 1: 'Block height', 2: 'Uptime', 3: 'Total nodes', 4: 'Total staked', @@ -36,27 +28,27 @@ context('Home Page', function () { cy.get('[data-testid="stats-title"]') .each(($list, index) => { - cy.wrap($list).should('have.text', statTitles[index]); + cy.wrap($list).should('contain.text', statTitles[index]); }) .then(($list) => { cy.wrap($list).should('have.length', 16); }); - cy.get(statsValue).eq(0).should('have.text', 'CONNECTED'); + cy.get(statsValue).eq(0).should('contain.text', 'CONNECTED'); cy.get(statsValue).eq(1).should('not.be.empty'); cy.get(statsValue) .eq(2) .invoke('text') .should('match', /\d+d \d+h \d+m \d+s/i); - cy.get(statsValue).eq(3).should('have.text', '2'); + cy.get(statsValue).eq(3).should('contain.text', '2'); cy.get(statsValue) .eq(4) .invoke('text') .should('match', /\d+\.\d\d(?!\d)/i); - cy.get(statsValue).eq(5).should('have.text', '0'); - cy.get(statsValue).eq(6).should('have.text', '0'); - cy.get(statsValue).eq(7).should('have.text', '0'); - cy.get(statsValue).eq(8).should('have.text', '0'); + cy.get(statsValue).eq(5).should('contain.text', '0'); + cy.get(statsValue).eq(6).should('contain.text', '0'); + cy.get(statsValue).eq(7).should('contain.text', '0'); + cy.get(statsValue).eq(8).should('contain.text', '0'); cy.get(statsValue).eq(9).should('not.be.empty'); cy.get(statsValue).eq(10).should('not.be.empty'); cy.get(statsValue).eq(11).should('not.be.empty'); @@ -86,75 +78,4 @@ context('Home Page', function () { }); }); }); - - describe('Git info', function () { - it('git info is rendered on the footer of the page', function () { - cy.getByTestId('git-info').within(() => { - cy.getByTestId('git-network-data').within(() => { - cy.contains('Reading network data from').should('be.visible'); - cy.get('span').should('have.text', Cypress.env('networkQueryUrl')); - cy.getByTestId('link').should('be.visible'); - }); - - cy.getByTestId('git-eth-data').within(() => { - cy.contains('Reading Ethereum data from').should('be.visible'); - cy.get('span').should('have.text', Cypress.env('ethUrl')); - }); - - cy.getByTestId('git-commit-hash').within(() => { - cy.contains('Version/commit hash:').should('be.visible'); - cy.getByTestId('link').should('have.text', Cypress.env('commitHash')); - }); - }); - }); - }); - - describe('Search bar', function () { - it('Successful search for specific id by block id', function () { - const blockId = '973624'; - search(blockId); - cy.url().should('include', blockId); - }); - - it('Successful search for specific id by tx hash', function () { - const txHash = - '9ED3718AA8308E7E08EC588EE7AADAF49711D2138860D8914B4D81A2054D9FB8'; - search(txHash); - cy.url().should('include', txHash); - }); - - it('Successful search for specific id by tx id', function () { - const txId = - '0x61DCCEBB955087F50D0B85382DAE138EDA9631BF1A4F92E563D528904AA38898'; - search(txId); - cy.url().should('include', txId); - }); - - it('Error message displayed when invalid search by wrong string length', function () { - search('9ED3718AA8308E7E08EC588EE7AADAF497D2138860D8914B4D81A2054D9FB8'); - validateSearchError("Something doesn't look right"); - }); - - it('Error message displayed when invalid search by invalid hash', function () { - search( - '9ED3718AA8308E7E08ECht8EE753DAF49711D2138860D8914B4D81A2054D9FB8' - ); - validateSearchError('Transaction is not hexadecimal'); - }); - - it('Error message displayed when searching empty field', function () { - cy.get('[data-testid="search"]').clear(); - cy.get('[data-testid="search-button"]').click(); - validateSearchError('Search required'); - }); - - function search(searchTxt) { - cy.get('[data-testid="search"]').clear().type(searchTxt); - cy.get('[data-testid="search-button"]').click(); - } - - function validateSearchError(expectedError) { - cy.get('[data-testid="search-error"]').should('have.text', expectedError); - } - }); }); diff --git a/apps/explorer/src/app/app.tsx b/apps/explorer/src/app/app.tsx index 87638b3a8..7afe05ce2 100644 --- a/apps/explorer/src/app/app.tsx +++ b/apps/explorer/src/app/app.tsx @@ -1,87 +1,21 @@ import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment'; -import { Header } from './components/header'; -import { Main } from './components/main'; import { TendermintWebsocketProvider } from './contexts/websocket/tendermint-websocket-provider'; -import { Footer } from './components/footer/footer'; -import { - AnnouncementBanner, - ExternalLink, - Icon, -} from '@vegaprotocol/ui-toolkit'; -import { - AssetDetailsDialog, - useAssetDetailsDialogStore, -} from '@vegaprotocol/assets'; +import { Loader, Splash } from '@vegaprotocol/ui-toolkit'; import { DEFAULT_CACHE_CONFIG } from '@vegaprotocol/apollo-client'; -import classNames from 'classnames'; -import { useState } from 'react'; +import { RouterProvider } from 'react-router-dom'; +import { router } from './routes/router-config'; -const DialogsContainer = () => { - const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore(); - return ( - - ); -}; - -const MainnetSimAd = () => { - const [shouldDisplayBanner, setShouldDisplayBanner] = useState(true); - - // Return an empty div so that the grid layout in _app.page.ts - // renders correctly - if (!shouldDisplayBanner) { - return
; - } - - return ( - -
- -
- Mainnet sim 3 is live! - Learn more -
-
-
- ); -}; +const splashLoading = ( + + + +); function App() { return ( -
-
- -
-
-
-
-
-
-
-
-
- +
); diff --git a/apps/explorer/src/app/components/header/header.spec.tsx b/apps/explorer/src/app/components/header/header.spec.tsx index f640c7bde..3dab8fca6 100644 --- a/apps/explorer/src/app/components/header/header.spec.tsx +++ b/apps/explorer/src/app/components/header/header.spec.tsx @@ -13,7 +13,7 @@ jest.mock('../search', () => ({ })); const renderComponent = () => ( - +
); @@ -24,6 +24,7 @@ describe('Header', () => { expect(screen.getByTestId('navigation')).toHaveTextContent('Explorer'); }); + it('should render search', () => { render(renderComponent()); diff --git a/apps/explorer/src/app/components/header/header.tsx b/apps/explorer/src/app/components/header/header.tsx index 2230193e6..dd30c0443 100644 --- a/apps/explorer/src/app/components/header/header.tsx +++ b/apps/explorer/src/app/components/header/header.tsx @@ -1,4 +1,4 @@ -import { matchPath, useLocation } from 'react-router-dom'; +import { matchPath, useLocation, useMatch } from 'react-router-dom'; import { ThemeSwitcher, Navigation, @@ -13,23 +13,26 @@ import { t } from '@vegaprotocol/i18n'; import { Routes } from '../../routes/route-names'; import { NetworkSwitcher } from '@vegaprotocol/environment'; import type { Navigable } from '../../routes/router-config'; -import routerConfig from '../../routes/router-config'; +import { isNavigable } from '../../routes/router-config'; +import { routerConfig } from '../../routes/router-config'; import { useMemo } from 'react'; import compact from 'lodash/compact'; import { Search } from '../search'; const routeToNavigationItem = (r: Navigable) => ( - - {r.text} + + {r.handle.text} ); export const Header = () => { + const isHome = Boolean(useMatch(Routes.HOME)); + const pages = routerConfig[0].children || []; const mainItems = compact( [Routes.TX, Routes.BLOCKS, Routes.ORACLES, Routes.VALIDATORS].map((n) => - routerConfig.find((r) => r.path === n) + pages.find((r) => r.path === n) ) - ); + ).filter(isNavigable); const groupedItems = compact( [ @@ -39,8 +42,8 @@ export const Header = () => { Routes.GOVERNANCE, Routes.NETWORK_PARAMETERS, Routes.GENESIS, - ].map((n) => routerConfig.find((r) => r.path === n)) - ); + ].map((n) => pages.find((r) => r.path === n)) + ).filter(isNavigable); const { pathname } = useLocation(); @@ -67,7 +70,7 @@ export const Header = () => { actions={ <> - + {!isHome && } } onResize={(width, el) => { @@ -90,7 +93,7 @@ export const Header = () => { hide={[NavigationBreakpoint.Small, NavigationBreakpoint.Narrow]} > {mainItems.map(routeToNavigationItem)} - {groupedItems && ( + {groupedItems && groupedItems.length > 0 && ( {t('Other')} diff --git a/apps/explorer/src/app/components/main/index.tsx b/apps/explorer/src/app/components/main/index.tsx deleted file mode 100644 index 022d5f98c..000000000 --- a/apps/explorer/src/app/components/main/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { AppRouter } from '../../routes'; - -export const Main = () => { - return ( -
- -
- ); -}; diff --git a/apps/explorer/src/app/components/router-error-boundary/index.tsx b/apps/explorer/src/app/components/router-error-boundary/index.tsx deleted file mode 100644 index 2dec60db8..000000000 --- a/apps/explorer/src/app/components/router-error-boundary/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { t } from '@vegaprotocol/i18n'; -import React from 'react'; - -interface RouteErrorBoundaryProps { - children: React.ReactElement; -} - -export class RouteErrorBoundary extends React.Component< - RouteErrorBoundaryProps, - { hasError: boolean } -> { - constructor(props: RouteErrorBoundaryProps) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - override componentDidCatch(error: Error) { - console.log(`Error caught in App error boundary ${error.message}`, error); - } - - override render() { - if (this.state.hasError) { - return

{t('Something went wrong')}

; - } - - return this.props.children; - } -} diff --git a/apps/explorer/src/app/components/search/detect-search.spec.ts b/apps/explorer/src/app/components/search/detect-search.spec.ts index 1a321013b..c25463754 100644 --- a/apps/explorer/src/app/components/search/detect-search.spec.ts +++ b/apps/explorer/src/app/components/search/detect-search.spec.ts @@ -1,189 +1,70 @@ import { - detectTypeByFetching, - detectTypeFromQuery, - getSearchType, + determineType, isBlock, - isHexadecimal, isNetworkParty, - isNonHex, SearchTypes, - toHex, - toNonHex, + isHash, } from './detect-search'; -import { DATA_SOURCES } from '../../config'; global.fetch = jest.fn(); describe('Detect Search', () => { - it("should detect that it's a hexadecimal", () => { - const expected = true; - const testString = - '0x073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9'; - const actual = isHexadecimal(testString); - expect(actual).toBe(expected); - }); - - it("should detect that it's not hexadecimal", () => { - const expected = true; - const testString = - '073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9'; - const actual = isNonHex(testString); - expect(actual).toBe(expected); + it.each([ + ['0000000000000000000000000000000000000000000000000000000000000000', true], + ['0000000000000000000000000000000000000000000000000000000000000001', true], + [ + 'LOOONG0000000000000000000000000000000000000000000000000000000000000000', + false, + ], + ['xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', false], + ['something else', false], + ])("should detect that it's a hash", (input, expected) => { + expect(isHash(input)).toBe(expected); }); it("should detect that it's a network party", () => { - const expected = true; - const testString = 'network'; - const actual = isNetworkParty(testString); - expect(actual).toBe(expected); + expect(isNetworkParty('network')).toBe(true); + expect(isNetworkParty('web')).toBe(false); }); it("should detect that it's a block", () => { - const expected = true; - const testString = '3188'; - const actual = isBlock(testString); - expect(actual).toBe(expected); + expect(isBlock('123')).toBe(true); + expect(isBlock('x123')).toBe(false); }); - it('should convert from non-hex to hex', () => { - const expected = '0x123'; - const testString = '123'; - const actual = toHex(testString); - expect(actual).toBe(expected); - }); - - it('should convert from hex to non-hex', () => { - const expected = '123'; - const testString = '0x123'; - const actual = toNonHex(testString); - expect(actual).toBe(expected); - }); - - it("should detect type client side from query if it's a hexadecimal", () => { - const expected = [SearchTypes.Party, SearchTypes.Transaction]; - const testString = - '0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; - const actual = detectTypeFromQuery(testString); - expect(actual).toStrictEqual(expected); - }); - - it("should detect type client side from query if it's a non hex", () => { - const expected = [SearchTypes.Party, SearchTypes.Transaction]; - const testString = - '4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; - const actual = detectTypeFromQuery(testString); - expect(actual).toStrictEqual(expected); - }); - - it("should detect type client side from query if it's a network party", () => { - const expected = [SearchTypes.Party]; - const testString = 'network'; - const actual = detectTypeFromQuery(testString); - expect(actual).toStrictEqual(expected); - }); - - it("should detect type client side from query if it's a block (number)", () => { - const expected = [SearchTypes.Block]; - const testString = '23432'; - const actual = detectTypeFromQuery(testString); - expect(actual).toStrictEqual(expected); - }); - - it("detectTypeByFetching should call fetch with non-hex query it's a transaction", async () => { - const query = '0xabc'; - const type = SearchTypes.Transaction; - // @ts-ignore issue related to polyfill - fetch.mockImplementation( - jest.fn(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - transaction: { - hash: query, - }, - }), - }) - ) - ); - const result = await detectTypeByFetching(query); - expect(fetch).toHaveBeenCalledWith( - `${DATA_SOURCES.blockExplorerUrl}/transactions/${toNonHex(query)}` - ); - expect(result).toBe(type); - }); - - it("detectTypeByFetching should call fetch with non-hex query it's a party", async () => { - const query = 'abc'; - const type = SearchTypes.Party; - // @ts-ignore issue related to polyfill - fetch.mockImplementation( - jest.fn(() => - Promise.resolve({ - ok: false, - }) - ) - ); - const result = await detectTypeByFetching(query); - expect(result).toBe(type); - }); - - it('getSearchType should return party from fetch response', async () => { - const query = - '0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; - const expected = SearchTypes.Party; - // @ts-ignore issue related to polyfill - fetch.mockImplementation( - jest.fn(() => - Promise.resolve({ - ok: false, - }) - ) - ); - const result = await getSearchType(query); - expect(result).toBe(expected); - }); - - it('getSearchType should return transaction from fetch response', async () => { - const query = - '4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; - const expected = SearchTypes.Transaction; - // @ts-ignore issue related to polyfill - fetch.mockImplementation( - jest.fn(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - transaction: { - hash: query, - }, - }), - }) - ) - ); - const result = await getSearchType(query); - expect(result).toBe(expected); - }); - - it('getSearchType should return undefined from transaction response', async () => { - const query = 'u'; - const expected = undefined; - const result = await getSearchType(query); - expect(result).toBe(expected); - }); - - it('getSearchType should return block if query is number', async () => { - const query = '123'; - const expected = SearchTypes.Block; - const result = await getSearchType(query); - expect(result).toBe(expected); - }); - - it('getSearchType should return party if query is network', async () => { - const query = 'network'; - const expected = SearchTypes.Party; - const result = await getSearchType(query); - expect(result).toBe(expected); - }); + it.each([ + [ + '0000000000000000000000000000000000000000000000000000000000000000', + SearchTypes.Transaction, + ], + [ + '0000000000000000000000000000000000000000000000000000000000000001', + SearchTypes.Party, + ], + ['123', SearchTypes.Block], + ['network', SearchTypes.Party], + ['something else', SearchTypes.Unknown], + ])( + "detectTypeByFetching should call fetch with non-hex query it's a transaction", + async (input, type) => { + // @ts-ignore issue related to polyfill + fetch.mockImplementation( + jest.fn(() => + Promise.resolve({ + ok: + input === + '0000000000000000000000000000000000000000000000000000000000000000', + json: () => + Promise.resolve({ + transaction: { + hash: input, + }, + }), + }) + ) + ); + const result = await determineType(input); + expect(result).toBe(type); + } + ); }); diff --git a/apps/explorer/src/app/components/search/detect-search.ts b/apps/explorer/src/app/components/search/detect-search.ts index 19297f1bf..5c37cc1c0 100644 --- a/apps/explorer/src/app/components/search/detect-search.ts +++ b/apps/explorer/src/app/components/search/detect-search.ts @@ -1,3 +1,4 @@ +import { remove0x } from '@vegaprotocol/utils'; import { DATA_SOURCES } from '../../config'; import type { BlockExplorerTransaction } from '../../routes/types/block-explorer-response'; @@ -6,15 +7,20 @@ export enum SearchTypes { Party = 'party', Block = 'block', Order = 'order', + Unknown = 'unknown', } -export const TX_LENGTH = 64; +export const HASH_LENGTH = 64; + +export const isHash = (value: string) => + /[0-9a-fA-F]+/.test(remove0x(value)) && + remove0x(value).length === HASH_LENGTH; export const isHexadecimal = (search: string) => - search.startsWith('0x') && search.length === 2 + TX_LENGTH; + search.startsWith('0x') && search.length === 2 + HASH_LENGTH; export const isNonHex = (search: string) => - !search.startsWith('0x') && search.length === TX_LENGTH; + !search.startsWith('0x') && search.length === HASH_LENGTH; export const isBlock = (search: string) => !Number.isNaN(Number(search)); @@ -23,121 +29,44 @@ export const isNetworkParty = (search: string) => search === 'network'; export const toHex = (query: string) => isHexadecimal(query) ? query : `0x${query}`; -export const toNonHex = (query: string) => - isNonHex(query) ? query : `${query.replace('0x', '')}`; +export const toNonHex = remove0x; -export const detectTypeFromQuery = ( - query: string -): SearchTypes[] | undefined => { - const i = query.toLowerCase(); - - if (isHexadecimal(i) || isNonHex(i)) { - return [SearchTypes.Party, SearchTypes.Transaction]; - } else if (isNetworkParty(i)) { - return [SearchTypes.Party]; - } else if (isBlock(i)) { - return [SearchTypes.Block]; +/** + * Determine the type of the given query + */ +export const determineType = async (query: string): Promise => { + const value = query.toLowerCase(); + if (isHash(value)) { + // it can be either `SearchTypes.Party` or `SearchTypes.Transaction` + if (await isTransactionHash(value)) { + return SearchTypes.Transaction; + } else { + return SearchTypes.Party; + } + } else if (isNetworkParty(value)) { + return SearchTypes.Party; + } else if (isBlock(value)) { + return SearchTypes.Block; } - - return undefined; + return SearchTypes.Unknown; }; -export const detectTypeByFetching = async ( - query: string -): Promise => { - const hash = toNonHex(query); +/** + * Checks if given input is a transaction hash by querying the transactions + * endpoint + */ +export const isTransactionHash = async (input: string): Promise => { + const hash = remove0x(input); const request = await fetch( `${DATA_SOURCES.blockExplorerUrl}/transactions/${hash}` ); if (request?.ok) { const body: BlockExplorerTransaction = await request.json(); - if (body?.transaction) { - return SearchTypes.Transaction; + return true; } } - return SearchTypes.Party; -}; - -// Code commented out because the current solution to detect a hex is temporary (by process of elimination) -// export const detectTypeByFetching = async ( -// query: string, -// type: SearchTypes -// ): Promise => { -// const TYPES = [SearchTypes.Party, SearchTypes.Transaction]; -// -// if (!TYPES.includes(type)) { -// throw new Error('Search type provided not recognised'); -// } -// -// if (type === SearchTypes.Transaction) { -// const hash = toNonHex(query); -// const request = await fetch( -// `${DATA_SOURCES.blockExplorerUrl}/transactions/${hash}` -// ); -// -// if (request?.ok) { -// const body: BlockExplorerTransaction = await request.json(); -// -// if (body?.transaction) { -// return SearchTypes.Transaction; -// } -// } -// } else if (type === SearchTypes.Party) { -// const party = toNonHex(query); -// -// const request = await fetch( -// `${DATA_SOURCES.blockExplorerUrl}/transactions?limit=1&filters[tx.submitter]=${party}` -// ); -// -// if (request.ok) { -// const body: BlockExplorerTransactions = await request.json(); -// -// if (body?.transactions?.length) { -// return SearchTypes.Party; -// } -// } -// } -// -// return undefined; -// }; - -// export const getSearchType = async ( -// query: string -// ): Promise => { -// const searchTypes = detectTypeFromQuery(query); -// const hasResults = searchTypes?.length; -// -// if (hasResults) { -// if (hasResults > 1) { -// const promises = searchTypes.map((type) => -// detectTypeByFetching(query, type) -// ); -// const results = await Promise.all(promises); -// return results.find((result) => result !== undefined); -// } -// -// return searchTypes[0]; -// } -// -// return undefined; -// }; - -export const getSearchType = async ( - query: string -): Promise => { - const searchTypes = detectTypeFromQuery(query); - const hasResults = searchTypes?.length; - - if (hasResults) { - if (hasResults > 1) { - return await detectTypeByFetching(query); - } - - return searchTypes[0]; - } - - return undefined; + return false; }; diff --git a/apps/explorer/src/app/components/search/search.spec.tsx b/apps/explorer/src/app/components/search/search.spec.tsx index 4f2eeff25..62bde60bb 100644 --- a/apps/explorer/src/app/components/search/search.spec.tsx +++ b/apps/explorer/src/app/components/search/search.spec.tsx @@ -1,157 +1,82 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { Search } from './search'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { SearchForm } from './search'; import { MemoryRouter } from 'react-router-dom'; import { Routes } from '../../routes/route-names'; -import { SearchTypes, getSearchType } from './detect-search'; +global.fetch = jest.fn(); const mockedNavigate = jest.fn(); -const mockGetSearchType = getSearchType as jest.MockedFunction< - typeof getSearchType ->; - jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockedNavigate, })); -jest.mock('./detect-search', () => ({ - ...jest.requireActual('./detect-search'), - getSearchType: jest.fn(), -})); - beforeEach(() => { mockedNavigate.mockClear(); }); -const renderComponent = () => ( - - - -); +const renderComponent = () => + render( + + + + ); -const getInputs = () => ({ - input: screen.getByTestId('search'), - button: screen.getByTestId('search-button'), -}); - -describe('Search', () => { +describe('SearchForm', () => { it('should render search input and button', () => { - render(renderComponent()); + renderComponent(); expect(screen.getByTestId('search')).toBeInTheDocument(); expect(screen.getByTestId('search-button')).toHaveTextContent('Search'); }); - it('should render error if input is not known', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - fireEvent.change(input, { target: { value: 'asd' } }); - fireEvent.click(button); - - expect(await screen.findByTestId('search-error')).toHaveTextContent( - 'Transaction type is not recognised' + it.each([ + [ + Routes.TX, + '0000000000000000000000000000000000000000000000000000000000000000', + ], + [ + Routes.PARTIES, + '0000000000000000000000000000000000000000000000000000000000000001', + ], + [Routes.BLOCKS, '123'], + [undefined, 'something else'], + ])('should redirect to %s', async (route, input) => { + // @ts-ignore issue related to polyfill + fetch.mockImplementation( + jest.fn(() => + Promise.resolve({ + ok: + input === + '0000000000000000000000000000000000000000000000000000000000000000', + json: () => + Promise.resolve({ + transaction: { + hash: input, + }, + }), + }) + ) ); - }); - - it('should render error if no input is given', async () => { - render(renderComponent()); - const { button } = getInputs(); - - fireEvent.click(button); - - expect(await screen.findByTestId('search-error')).toHaveTextContent( - 'Search query required' - ); - }); - - it('should redirect to transactions page', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - mockGetSearchType.mockResolvedValue(SearchTypes.Transaction); - fireEvent.change(input, { - target: { - value: - '0x1234567890123456789012345678901234567890123456789012345678901234', - }, + renderComponent(); + await act(async () => { + fireEvent.change(screen.getByTestId('search'), { + target: { + value: input, + }, + }); + fireEvent.click(screen.getByTestId('search-button')); }); - - fireEvent.click(button); await waitFor(() => { - expect(mockedNavigate).toBeCalledWith( - `${Routes.TX}/0x1234567890123456789012345678901234567890123456789012345678901234` - ); - }); - }); - - it('should redirect to transactions page without proceeding 0x', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - mockGetSearchType.mockResolvedValue(SearchTypes.Transaction); - fireEvent.change(input, { - target: { - value: - '1234567890123456789012345678901234567890123456789012345678901234', - }, - }); - - fireEvent.click(button); - await waitFor(() => { - expect(mockedNavigate).toBeCalledWith( - `${Routes.TX}/0x1234567890123456789012345678901234567890123456789012345678901234` - ); - }); - }); - - it('should redirect to parties page', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - mockGetSearchType.mockResolvedValue(SearchTypes.Party); - fireEvent.change(input, { - target: { - value: - '0x1234567890123456789012345678901234567890123456789012345678901234', - }, - }); - - fireEvent.click(button); - await waitFor(() => { - expect(mockedNavigate).toBeCalledWith( - `${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234` - ); - }); - }); - - it('should redirect to parties page without proceeding 0x', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - mockGetSearchType.mockResolvedValue(SearchTypes.Party); - fireEvent.change(input, { - target: { - value: - '1234567890123456789012345678901234567890123456789012345678901234', - }, - }); - - fireEvent.click(button); - await waitFor(() => { - expect(mockedNavigate).toBeCalledWith( - `${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234` - ); - }); - }); - - it('should redirect to blocks page if passed a number', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - mockGetSearchType.mockResolvedValue(SearchTypes.Block); - fireEvent.change(input, { - target: { - value: '123', - }, - }); - - fireEvent.click(button); - await waitFor(() => { - expect(mockedNavigate).toBeCalledWith(`${Routes.BLOCKS}/123`); + expect(mockedNavigate).toBeCalledTimes(route ? 1 : 0); + if (route) { + // eslint-disable-next-line jest/no-conditional-expect + expect(mockedNavigate).toBeCalledWith(`${route}/${input}`); + } }); }); }); diff --git a/apps/explorer/src/app/components/search/search.tsx b/apps/explorer/src/app/components/search/search.tsx index 1d599c401..290122256 100644 --- a/apps/explorer/src/app/components/search/search.tsx +++ b/apps/explorer/src/app/components/search/search.tsx @@ -1,12 +1,13 @@ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { t } from '@vegaprotocol/i18n'; import { Button, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import { getSearchType, SearchTypes, toHex } from './detect-search'; import { Routes } from '../../routes/route-names'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import classNames from 'classnames'; +import { remove0x } from '@vegaprotocol/utils'; +import { determineType, isBlock, isHash, SearchTypes } from './detect-search'; interface FormFields { search: string; @@ -30,101 +31,26 @@ const MagnifyingGlass = () => ( ); +const Clear = () => ( + + + + +); + export const Search = () => { - const { register, handleSubmit } = useForm(); - const navigate = useNavigate(); - const [error, setError] = useState(null); - - const onSubmit = useCallback( - async (fields: FormFields) => { - setError(null); - - const query = fields.search; - - if (!query) { - return setError(new Error(t('Search query required'))); - } - - const result = await getSearchType(query); - const urlAsHex = toHex(query); - const unrecognisedError = new Error( - t('Transaction type is not recognised') - ); - - if (result) { - switch (result) { - case SearchTypes.Party: - return navigate(`${Routes.PARTIES}/${urlAsHex}`); - case SearchTypes.Transaction: - return navigate(`${Routes.TX}/${urlAsHex}`); - case SearchTypes.Block: - return navigate(`${Routes.BLOCKS}/${Number(query)}`); - default: - return setError(unrecognisedError); - } - } - - return setError(unrecognisedError); - }, - [navigate] - ); - - const searchForm = ( -
-
- - - - {error?.message && ( -
- - {error.message} - -
- )} - -
-
- ); + const searchForm = ; const searchTrigger = ( @@ -154,10 +80,135 @@ export const Search = () => { return ( <> -
{searchForm}
+
+ {searchForm} +
{searchTrigger}
); }; + +export const SearchForm = () => { + const { + register, + handleSubmit, + setValue, + setError, + clearErrors, + formState, + watch, + } = useForm(); + const navigate = useNavigate(); + + const onSubmit = useCallback( + async (fields: FormFields) => { + clearErrors(); + const type = await determineType(fields.search); + if (type) { + switch (type) { + case SearchTypes.Party: + return navigate(`${Routes.PARTIES}/${remove0x(fields.search)}`); + case SearchTypes.Transaction: + return navigate(`${Routes.TX}/${remove0x(fields.search)}`); + case SearchTypes.Block: + return navigate(`${Routes.BLOCKS}/${Number(fields.search)}`); + } + } + + setError('search', new Error(t('The search term is not a valid query'))); + }, + [clearErrors, navigate, setError] + ); + + const searchQuery = watch('search', ''); + + return ( +
+
+
+ + + + + isHash(value) || + isBlock(value) || + t('Search query has to be a number or a 64 character hash'), + onBlur: () => clearErrors('search'), + })} + id="search" + data-testid="search" + className={classNames( + 'pl-8 py-2 text-xs', + { 'pr-8': searchQuery.length > 1 }, + 'border rounded border-vega-light-200 dark:border-vega-dark-200', + { + 'border-vega-pink dark:border-vega-pink': Boolean( + formState.errors.search + ), + } + )} + hasError={Boolean(formState.errors.search)} + type="text" + placeholder={t( + 'Enter block number, public key or transaction hash' + )} + /> +
+ {formState.errors.search && ( +
+ + {formState.errors.search.message} + +
+ )} + +
+
+ ); +}; diff --git a/apps/explorer/src/app/routes/home/index.tsx b/apps/explorer/src/app/routes/home/index.tsx index d23c86922..49b07974b 100644 --- a/apps/explorer/src/app/routes/home/index.tsx +++ b/apps/explorer/src/app/routes/home/index.tsx @@ -1,14 +1,19 @@ import { StatsManager } from '@vegaprotocol/network-stats'; +import { SearchForm } from '../../components/search'; import { useDocumentTitle } from '../../hooks/use-document-title'; const Home = () => { - const classnames = 'mt-4 grid grid-cols-1 lg:grid-cols-2 lg:gap-4'; - + const classnames = 'mt-4 mb-4'; useDocumentTitle(); return (
- +
+ +
+
+ +
); }; diff --git a/apps/explorer/src/app/routes/index.tsx b/apps/explorer/src/app/routes/index.tsx deleted file mode 100644 index edbcf7df0..000000000 --- a/apps/explorer/src/app/routes/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { useRoutes } from 'react-router-dom'; -import { RouteErrorBoundary } from '../components/router-error-boundary'; - -import routerConfig from './router-config'; -import { Loader, Splash } from '@vegaprotocol/ui-toolkit'; - -export interface RouteChildProps { - name: string; -} - -export const AppRouter = () => { - const routes = useRoutes(routerConfig); - - const splashLoading = ( - - - - ); - - return ( - - {routes} - - ); -}; diff --git a/apps/explorer/src/app/routes/layout.tsx b/apps/explorer/src/app/routes/layout.tsx new file mode 100644 index 000000000..ee3aa900e --- /dev/null +++ b/apps/explorer/src/app/routes/layout.tsx @@ -0,0 +1,187 @@ +import { + AssetDetailsDialog, + useAssetDetailsDialogStore, +} from '@vegaprotocol/assets'; +import { t } from '@vegaprotocol/i18n'; +import { + AnnouncementBanner, + BackgroundVideo, + BreadcrumbsContainer, + ButtonLink, + ExternalLink, + Icon, +} from '@vegaprotocol/ui-toolkit'; +import classNames from 'classnames'; +import { useState } from 'react'; +import { + isRouteErrorResponse, + Link, + Outlet, + useMatch, + useRouteError, +} from 'react-router-dom'; +import { Footer } from '../components/footer/footer'; +import { Header } from '../components/header'; +import { Routes } from './route-names'; + +const DialogsContainer = () => { + const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore(); + return ( + + ); +}; + +const MainnetSimAd = () => { + const [shouldDisplayBanner, setShouldDisplayBanner] = useState(true); + + // Return an empty div so that the grid layout in _app.page.ts + // renders correctly + if (!shouldDisplayBanner) { + return
; + } + + return ( + +
+ +
+ Mainnet sim 3 is live! + Learn more +
+
+
+ ); +}; + +export const Layout = () => { + const isHome = Boolean(useMatch(Routes.HOME)); + return ( + <> +
+
+ +
+
+
+
+ {!isHome && } + +
+
+
+
+
+
+ + + ); +}; + +export const ErrorBoundary = () => { + const error = useRouteError(); + + const errorTitle = isRouteErrorResponse(error) + ? `${error.status} ${error.statusText}` + : t('Something went wrong'); + + const errorMessage = isRouteErrorResponse(error) + ? error.error?.message + : (error as Error).message || JSON.stringify(error); + + return ( + <> + +
+
+
{GHOST}
+

+ {errorTitle} +

+
+
+ {errorMessage} +
+
+ window.location.reload()}> + {t('Try refreshing')} + {' '} + {t('or go back to')}{' '} + + {t('Home')} + +
+
+ + ); +}; + +const GHOST = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/apps/explorer/src/app/routes/router-config.tsx b/apps/explorer/src/app/routes/router-config.tsx index f8a5b7e5d..c59d42165 100644 --- a/apps/explorer/src/app/routes/router-config.tsx +++ b/apps/explorer/src/app/routes/router-config.tsx @@ -8,7 +8,6 @@ import { Oracle } from './oracles/id'; import Party from './parties'; import { Parties } from './parties/home'; import { Party as PartySingle } from './parties/id'; -import Txs from './txs'; import { ValidatorsPage } from './validators'; import Genesis from './genesis'; import { Block } from './blocks/id'; @@ -20,23 +19,55 @@ import flags from '../config/flags'; import { t } from '@vegaprotocol/i18n'; import { Routes } from './route-names'; import { NetworkParameters } from './network-parameters'; -import type { RouteObject } from 'react-router-dom'; +import type { Params, RouteObject } from 'react-router-dom'; +import { createBrowserRouter } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { MarketPage, MarketsPage } from './markets'; +import type { ReactNode } from 'react'; +import { ErrorBoundary, Layout } from './layout'; +import compact from 'lodash/compact'; +import { AssetLink, MarketLink } from '../components/links'; +import { truncateMiddle } from '@vegaprotocol/ui-toolkit'; +import { remove0x } from '@vegaprotocol/utils'; export type Navigable = { path: string; - name: string; - text: string; + handle: { + name: string; + text: string; + }; +}; +export const isNavigable = (item: RouteObject): item is Navigable => + (item as Navigable).path !== undefined && + (item as Navigable).handle !== undefined && + (item as Navigable).handle.name !== undefined && + (item as Navigable).handle.text !== undefined; + +export type Breadcrumbable = { + handle: { breadcrumb: (data?: Params) => ReactNode | string }; +}; +export const isBreadcrumbable = (item: RouteObject): item is Breadcrumbable => + (item as Breadcrumbable).handle !== undefined && + (item as Breadcrumbable).handle.breadcrumb !== undefined; + +type RouteItem = + | RouteObject + | (RouteObject & Navigable) + | (RouteObject & Breadcrumbable); +type Route = RouteItem & { + children?: RouteItem[]; }; -type Route = RouteObject & Navigable; const partiesRoutes: Route[] = flags.parties ? [ { path: Routes.PARTIES, - name: t('Parties'), - text: t('Parties'), element: , + handle: { + name: t('Parties'), + text: t('Parties'), + breadcrumb: () => {t('Parties')}, + }, children: [ { index: true, @@ -45,6 +76,13 @@ const partiesRoutes: Route[] = flags.parties { path: ':party', element: , + handle: { + breadcrumb: (params: Params) => ( + + {truncateMiddle(params.party as string)} + + ), + }, }, ], }, @@ -55,8 +93,11 @@ const assetsRoutes: Route[] = flags.assets ? [ { path: Routes.ASSETS, - text: t('Assets'), - name: t('Assets'), + handle: { + name: t('Assets'), + text: t('Assets'), + breadcrumb: () => {t('Assets')}, + }, children: [ { index: true, @@ -65,6 +106,11 @@ const assetsRoutes: Route[] = flags.assets { path: ':assetId', element: , + handle: { + breadcrumb: (params: Params) => ( + + ), + }, }, ], }, @@ -75,8 +121,13 @@ const genesisRoutes: Route[] = flags.genesis ? [ { path: Routes.GENESIS, - name: t('Genesis'), - text: t('Genesis Parameters'), + handle: { + name: t('Genesis'), + text: t('Genesis Parameters'), + breadcrumb: () => ( + {t('Genesis Parameters')} + ), + }, element: , }, ] @@ -86,8 +137,13 @@ const governanceRoutes: Route[] = flags.governance ? [ { path: Routes.GOVERNANCE, - name: t('Governance proposals'), - text: t('Governance Proposals'), + handle: { + name: t('Governance proposals'), + text: t('Governance Proposals'), + breadcrumb: () => ( + {t('Governance Proposals')} + ), + }, element: , }, ] @@ -97,8 +153,11 @@ const marketsRoutes: Route[] = flags.markets ? [ { path: Routes.MARKETS, - name: t('Markets'), - text: t('Markets'), + handle: { + name: t('Markets'), + text: t('Markets'), + breadcrumb: () => {t('Markets')}, + }, children: [ { index: true, @@ -107,6 +166,11 @@ const marketsRoutes: Route[] = flags.markets { path: ':marketId', element: , + handle: { + breadcrumb: (params: Params) => ( + + ), + }, }, ], }, @@ -117,8 +181,15 @@ const networkParametersRoutes: Route[] = flags.networkParameters ? [ { path: Routes.NETWORK_PARAMETERS, - name: t('NetworkParameters'), - text: t('Network Parameters'), + handle: { + name: t('NetworkParameters'), + text: t('Network Parameters'), + breadcrumb: () => ( + + {t('Network Parameters')} + + ), + }, element: , }, ] @@ -128,80 +199,133 @@ const validators: Route[] = flags.validators ? [ { path: Routes.VALIDATORS, - name: t('Validators'), - text: t('Validators'), + handle: { + name: t('Validators'), + text: t('Validators'), + breadcrumb: () => ( + {t('Validators')} + ), + }, element: , }, ] : []; -const routerConfig: Route[] = [ +const linkTo = (...segments: (string | undefined)[]) => + compact(segments).join('/'); + +export const routerConfig: Route[] = [ { path: Routes.HOME, - name: t('Home'), - text: t('Home'), - element: , - index: true, - }, - { - path: Routes.TX, - name: t('Txs'), - text: t('Transactions'), - element: , - children: [ - { - path: 'pending', - element: , - }, - { - path: ':txHash', - element: , - }, - { - index: true, - element: , - }, - ], - }, - { - path: Routes.BLOCKS, - name: t('Blocks'), - text: t('Blocks'), - element: , + element: , + handle: { + name: t('Home'), + text: t('Home'), + breadcrumb: () => {t('Home')}, + }, + errorElement: , children: [ { index: true, - element: , + element: , }, { - path: ':block', - element: , + path: Routes.TX, + handle: { + name: t('Txs'), + text: t('Transactions'), + breadcrumb: () => {t('Transactions')}, + }, + children: [ + { + path: 'pending', + element: , + handle: { + breadcrumb: () => ( + + {t('Pending transactions')} + + ), + }, + }, + { + path: ':txHash', + element: , + handle: { + breadcrumb: (params: Params) => ( + + {truncateMiddle(remove0x(params.txHash as string))} + + ), + }, + }, + { + index: true, + element: , + }, + ], }, + { + path: Routes.BLOCKS, + handle: { + name: t('Blocks'), + text: t('Blocks'), + breadcrumb: () => {t('Blocks')}, + }, + element: , + children: [ + { + index: true, + element: , + }, + { + path: ':block', + element: , + handle: { + breadcrumb: (params: Params) => ( + + {params.block} + + ), + }, + }, + ], + }, + { + path: Routes.ORACLES, + handle: { + name: t('Oracles'), + text: t('Oracles'), + breadcrumb: () => {t('Oracles')}, + }, + element: , + children: [ + { + index: true, + element: , + }, + { + path: ':id', + element: , + handle: { + breadcrumb: (params: Params) => ( + + {truncateMiddle(params.id as string)} + + ), + }, + }, + ], + }, + ...partiesRoutes, + ...assetsRoutes, + ...genesisRoutes, + ...governanceRoutes, + ...marketsRoutes, + ...networkParametersRoutes, + ...validators, ], }, - { - path: Routes.ORACLES, - name: t('Oracles'), - text: t('Oracles'), - element: , - children: [ - { - index: true, - element: , - }, - { - path: ':id', - element: , - }, - ], - }, - ...partiesRoutes, - ...assetsRoutes, - ...genesisRoutes, - ...governanceRoutes, - ...marketsRoutes, - ...networkParametersRoutes, - ...validators, ]; -export default routerConfig; +export const router = createBrowserRouter(routerConfig); diff --git a/apps/explorer/src/app/routes/txs/id/index.tsx b/apps/explorer/src/app/routes/txs/id/index.tsx index f28eaba8c..aa6e962a1 100644 --- a/apps/explorer/src/app/routes/txs/id/index.tsx +++ b/apps/explorer/src/app/routes/txs/id/index.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { remove0x } from '@vegaprotocol/utils'; import { useFetch } from '@vegaprotocol/react-helpers'; import { DATA_SOURCES } from '../../../config'; @@ -8,9 +7,6 @@ import { TxDetails } from './tx-details'; import type { BlockExplorerTransaction } from '../../../routes/types/block-explorer-response'; import { toNonHex } from '../../../components/search/detect-search'; import { PageHeader } from '../../../components/page-header'; -import { Routes } from '../../../routes/route-names'; -import { IconNames } from '@blueprintjs/icons'; -import { Icon } from '@vegaprotocol/ui-toolkit'; import { useDocumentTitle } from '../../../hooks/use-document-title'; const Tx = () => { @@ -35,17 +31,6 @@ const Tx = () => { return (
- - - All Transactions - - - - - + ); diff --git a/libs/network-stats/src/components/promoted-stats-item/index.ts b/libs/network-stats/src/components/promoted-stats-item/index.ts deleted file mode 100644 index fe769eb91..000000000 --- a/libs/network-stats/src/components/promoted-stats-item/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PromotedStatsItem } from './promoted-stats-item'; diff --git a/libs/network-stats/src/components/promoted-stats-item/promoted-stats-item.tsx b/libs/network-stats/src/components/promoted-stats-item/promoted-stats-item.tsx deleted file mode 100644 index 7a68e6f94..000000000 --- a/libs/network-stats/src/components/promoted-stats-item/promoted-stats-item.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Callout, Indicator, Intent, Tooltip } from '@vegaprotocol/ui-toolkit'; -import type { StatFields } from '../../config/types'; -import { defaultFieldFormatter } from '../table-row'; -import { useMemo } from 'react'; - -export const PromotedStatsItem = ({ - title, - formatter, - goodThreshold, - value, - description, - ...props -}: StatFields) => { - const variant = useMemo( - () => - goodThreshold - ? goodThreshold(value) - ? Intent.Success - : Intent.Danger - : Intent.Primary, - [goodThreshold, value] - ); - return ( - - -
- - {title} -
-
- {formatter ? formatter(value) : defaultFieldFormatter(value)} -
-
-
- ); -}; diff --git a/libs/network-stats/src/components/promoted-stats/index.ts b/libs/network-stats/src/components/promoted-stats/index.ts deleted file mode 100644 index 799945377..000000000 --- a/libs/network-stats/src/components/promoted-stats/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PromotedStats } from './promoted-stats'; diff --git a/libs/network-stats/src/components/promoted-stats/promoted-stats.tsx b/libs/network-stats/src/components/promoted-stats/promoted-stats.tsx deleted file mode 100644 index 5cda047d5..000000000 --- a/libs/network-stats/src/components/promoted-stats/promoted-stats.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -interface PromotedStatsProps { - children: React.ReactNode; -} - -export const PromotedStats = ({ children }: PromotedStatsProps) => { - return ( -
- {children} -
- ); -}; diff --git a/libs/network-stats/src/components/stats-manager/stats-manager.tsx b/libs/network-stats/src/components/stats-manager/stats-manager.tsx index fa02d4521..2a88d56be 100644 --- a/libs/network-stats/src/components/stats-manager/stats-manager.tsx +++ b/libs/network-stats/src/components/stats-manager/stats-manager.tsx @@ -1,107 +1,89 @@ -import { useEffect } from 'react'; -import classnames from 'classnames'; import { useEnvironment } from '@vegaprotocol/environment'; -import { statsFields } from '../../config/stats-fields'; -import type { - Stats as IStats, - StructuredStats as IStructuredStats, -} from '../../config/types'; -import { Table } from '../table'; -import { TableRow } from '../table-row'; -import { PromotedStats } from '../promoted-stats'; -import { PromotedStatsItem } from '../promoted-stats-item'; +import type { Statistics, NodeData } from '../../config/stats-fields'; +import { fieldsDefinition } from '../../config/stats-fields'; import { useStatsQuery } from './__generated__/Stats'; -import type { StatsQuery } from './__generated__/Stats'; +import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit'; +import classNames from 'classnames'; +import { useEffect } from 'react'; interface StatsManagerProps { className?: string; } -const compileData = (data?: StatsQuery) => { - const { nodeData, statistics } = data || {}; - const returned = { ...nodeData, ...statistics }; - - // Loop through the stats fields config, grabbing values from the fetched - // data and building a set of promoted and standard table entries. - return Object.entries(statsFields).reduce( - (acc, [key, value]) => { - const statKey = key as keyof IStats; - const statData = returned[statKey]; - - value.forEach((x) => { - const stat = { - ...x, - value: statData || '-', - }; - - stat.promoted ? acc.promoted.push(stat) : acc.table.push(stat); - }); - - return acc; - }, - { promoted: [], table: [] } as IStructuredStats - ); -}; - export const StatsManager = ({ className }: StatsManagerProps) => { const { VEGA_ENV } = useEnvironment(); - const { data, error, startPolling, stopPolling } = useStatsQuery(); + const { data, startPolling, stopPolling } = useStatsQuery(); useEffect(() => { - startPolling(1000); + startPolling(500); return () => stopPolling(); - }); + }, [startPolling, stopPolling]); - const displayData = compileData(data); + const getValue = (field: keyof NodeData | keyof Statistics) => + ['stakedTotal', 'totalNodes', 'inactiveNodes'].includes(field) + ? data?.nodeData?.[field as keyof NodeData] + : data?.statistics?.[field as keyof Statistics]; - const classes = classnames( - className, - 'stats-grid w-full self-start justify-self-center' + const panels = fieldsDefinition.map( + ({ field, title, description, formatter, goodThreshold }) => ({ + field, + title, + description, + value: formatter ? formatter(getValue(field)) : getValue(field), + good: + goodThreshold && getValue(field) + ? goodThreshold(getValue(field)) + : undefined, + }) ); return ( -
-

- {(error && `/ ${error}`) || - (data ? `/ ${VEGA_ENV}` : '/ Connecting...')} -

- - {displayData?.promoted ? ( - - {displayData.promoted.map((stat, i) => { - return ( - - ); - })} - - ) : null} - - - {displayData?.table - ? displayData.table.map((stat, i) => { - return ( - - ); - }) - : null} -
+
+ {panels.map(({ field, title, description, value, good }, i) => ( +
+
+
+
{title}
+ {description && ( + +
+ +
+
+ )} +
+
+ {value} {field === 'status' && `(${VEGA_ENV})`} +
+
+ ))}
); }; diff --git a/libs/network-stats/src/components/table-row/index.ts b/libs/network-stats/src/components/table-row/index.ts deleted file mode 100644 index 665eaf966..000000000 --- a/libs/network-stats/src/components/table-row/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableRow, defaultFieldFormatter } from './table-row'; diff --git a/libs/network-stats/src/components/table-row/table-row.tsx b/libs/network-stats/src/components/table-row/table-row.tsx deleted file mode 100644 index e0b565948..000000000 --- a/libs/network-stats/src/components/table-row/table-row.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Tooltip } from '@vegaprotocol/ui-toolkit'; -import type { StatFields } from '../../config/types'; -import { useMemo } from 'react'; -import { Indicator, Intent } from '@vegaprotocol/ui-toolkit'; - -export const defaultFieldFormatter = (field: unknown) => - field === undefined ? 'no data' : field; - -export const TableRow = ({ - title, - formatter, - goodThreshold, - value, - description, - ...props -}: StatFields) => { - const variant = useMemo( - () => - goodThreshold - ? goodThreshold(value) - ? Intent.Success - : Intent.Danger - : Intent.None, - [goodThreshold, value] - ); - return ( - - - - {title} - - - {formatter ? formatter(value) : defaultFieldFormatter(value)} - - - - - - - ); -}; diff --git a/libs/network-stats/src/components/table/index.ts b/libs/network-stats/src/components/table/index.ts deleted file mode 100644 index 48232283c..000000000 --- a/libs/network-stats/src/components/table/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Table } from './table'; diff --git a/libs/network-stats/src/components/table/table.tsx b/libs/network-stats/src/components/table/table.tsx deleted file mode 100644 index cf535f6df..000000000 --- a/libs/network-stats/src/components/table/table.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -interface TableProps { - children: React.ReactNode; -} - -export const Table = ({ children }: TableProps) => { - return ( - - {children} -
- ); -}; diff --git a/libs/network-stats/src/config/stats-fields.ts b/libs/network-stats/src/config/stats-fields.ts index 5839d2543..96f8be4e5 100644 --- a/libs/network-stats/src/config/stats-fields.ts +++ b/libs/network-stats/src/config/stats-fields.ts @@ -5,204 +5,231 @@ import { isValidDate, } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; -import type { Stats, StatFields } from './types'; +import type * as Schema from '@vegaprotocol/types'; +import type { StatsQuery } from '../components/stats-manager/__generated__/Stats'; -// Stats fields config. Keys will correspond to graphql queries when used, and values -// contain the associated data and methods we need to render. A single query -// can be rendered in multiple ways (see 'upTime'). -export const statsFields: { [key in keyof Stats]: StatFields[] } = { - status: [ - { - title: t('Status'), - formatter: (status: string) => { - if (!status) { - return; - } +export type NodeData = Pick< + Schema.NodeData, + 'stakedTotal' | 'totalNodes' | 'inactiveNodes' +>; - const i = status.lastIndexOf('_'); - if (i === -1) { - return status; - } else { - return status.substring(i + 1); - } - }, - goodThreshold: (status: string) => - status === 'CONNECTED' || status === 'CHAIN_STATUS_CONNECTED', - promoted: true, - description: t( - 'Status is either connected, replaying, unspecified or disconnected' - ), - }, - ], - blockHeight: [ - { - title: t('Height'), - goodThreshold: (height: number) => height >= 60, - promoted: true, - description: t('Block height'), - }, - ], - totalNodes: [ - { - title: t('Total nodes'), - description: t('The total number of nodes registered on the network'), - }, - ], - inactiveNodes: [], - stakedTotal: [ - { - title: t('Total staked'), - formatter: (total: string) => { - return addDecimalsFormatNumber(total, 18, 2); - }, - description: t('Sum of VEGA associated with a Vega key'), - }, - ], - backlogLength: [ - { - title: t('Backlog'), - goodThreshold: (length: number, blockDuration: number) => { - return ( - length < 1000 || (length >= 1000 && blockDuration / 1000000000 <= 1) - ); - }, - description: t('Number of transactions waiting to be processed'), - }, - ], - tradesPerSecond: [ - { - title: t('Trades / second'), - goodThreshold: (trades: number) => trades >= 2, - description: t('Number of trades processed in the last second'), - }, - ], - averageOrdersPerBlock: [ - { - title: t('Orders / block'), - goodThreshold: (orders: number) => orders >= 2, - description: t( - 'Number of new orders processed in the last block. All pegged orders and liquidity provisions count as a single order' - ), - }, - ], - ordersPerSecond: [ - { - title: t('Orders / second'), - goodThreshold: (orders: number) => orders >= 2, - description: t( - 'Number of orders processed in the last second. All pegged orders and liquidity provisions count as a single order' - ), - }, - ], - txPerBlock: [ - { - title: t('Transactions / block'), - goodThreshold: (tx: number) => tx > 2, - description: t('Number of transactions processed in the last block'), - }, - ], - blockDuration: [ - { - title: t('Block time'), - formatter: (duration: string) => { - const dp = 3; - if (duration?.includes('ms')) { - return (parseFloat(duration) / 1000).toFixed(dp).toString(); - } else if (duration?.includes('s')) { - return parseFloat(duration).toFixed(dp).toString(); - } - return duration - ? (Number(duration) / 1000000000).toFixed(dp) - : undefined; - }, - goodThreshold: (blockDuration: string) => { - if (blockDuration?.includes('ms')) { - // we only get ms from the api if duration is less than 1s, so this - // automatically passes - return true; - } else if (blockDuration?.includes('s')) { - return parseFloat(blockDuration) <= 2; - } else { - return ( - Number(blockDuration) > 0 && Number(blockDuration) <= 2000000000 - ); - } - }, - description: t('Seconds between the two most recent blocks'), - }, - ], - vegaTime: [ - { - title: t('Time'), - formatter: (time: Date) => { - if (!time) { - return; - } - const date = new Date(time); - if (!isValidDate(date)) { - return; - } - return getTimeFormat().format(date); - }, - goodThreshold: (time: Date) => { - const diff = new Date().getTime() - new Date(time).getTime(); - return diff > 0 && diff < 5000; - }, - description: t('The time on the blockchain'), - }, - ], - appVersion: [ - { - title: t('App'), - description: t('Vega node software version on this node'), - }, - ], - chainVersion: [ - { - title: t('Tendermint'), - description: t('Tendermint software version on this node'), - }, - ], - genesisTime: [ - { - title: t('Uptime'), - formatter: (t: string) => { - if (!t) { - return '-'; - } - const date = new Date(t); - if (!isValidDate(date)) { - return '-'; - } - const secSinceStart = (new Date().getTime() - date.getTime()) / 1000; - const days = Math.floor(secSinceStart / 60 / 60 / 24); - const hours = Math.floor((secSinceStart / 60 / 60) % 24); - const mins = Math.floor((secSinceStart / 60) % 60); - const secs = Math.floor(secSinceStart % 60); - return `${days}d ${hours}h ${mins}m ${secs}s`; - }, - promoted: true, - description: t('Time since genesis'), - }, - { - title: t('Up since'), - formatter: (t: string) => { - if (!t) { - return '-'; - } - const date = new Date(t); - if (!isValidDate(date)) { - return '-'; - } - return getDateTimeFormat().format(date) || '-'; - }, - description: t('Genesis'), - }, - ], - chainId: [ - { - title: t('Chain ID'), - description: t('Identifier'), - }, - ], +export type Statistics = Omit; + +// eslint-disable-next-line +export type FieldValue = any; +export type GoodThreshold = (...args: FieldValue[]) => boolean; + +export interface Stats { + field: keyof NodeData | keyof Statistics; + title: string; + goodThreshold?: GoodThreshold; + // eslint-disable-next-line + formatter?: (arg0: FieldValue) => any; + promoted?: boolean; + value?: FieldValue; + description?: string; +} + +const STATUS: Stats = { + field: 'status', + title: t('Status'), + formatter: (status: string) => { + if (!status) { + return; + } + + const i = status.lastIndexOf('_'); + if (i === -1) { + return status; + } else { + return status.substring(i + 1); + } + }, + goodThreshold: (status: string) => + status === 'CONNECTED' || status === 'CHAIN_STATUS_CONNECTED', + description: t( + 'Status is either connected, replaying, unspecified or disconnected' + ), }; + +const BLOCK_HEIGHT: Stats = { + field: 'blockHeight', + title: t('Block height'), + goodThreshold: (height: number) => height >= 60, + description: t('Block height'), +}; + +const TOTAL_NODES: Stats = { + field: 'totalNodes', + title: t('Total nodes'), + description: t('The total number of nodes registered on the network'), +}; + +const TOTAL_STAKED: Stats = { + field: 'stakedTotal', + title: t('Total staked'), + formatter: (total: string) => { + return addDecimalsFormatNumber(total, 18, 2); + }, + description: t('Sum of VEGA associated with a Vega key'), +}; + +const BACKLOG_LENGTH: Stats = { + field: 'backlogLength', + title: t('Backlog'), + goodThreshold: (length: number, blockDuration: number) => { + return length < 1000 || (length >= 1000 && blockDuration / 1000000000 <= 1); + }, + description: t('Number of transactions waiting to be processed'), +}; + +const TRADES_PER_SECOND: Stats = { + field: 'tradesPerSecond', + title: t('Trades / second'), + goodThreshold: (trades: number) => trades >= 2, + description: t('Number of trades processed in the last second'), +}; + +const AVERAGE_ORDERS_PER_BLOCK: Stats = { + field: 'averageOrdersPerBlock', + title: t('Orders / block'), + goodThreshold: (orders: number) => orders >= 2, + description: t( + 'Number of new orders processed in the last block. All pegged orders and liquidity provisions count as a single order' + ), +}; + +const ORDERS_PER_SECOND: Stats = { + field: 'ordersPerSecond', + title: t('Orders / second'), + goodThreshold: (orders: number) => orders >= 2, + description: t( + 'Number of orders processed in the last second. All pegged orders and liquidity provisions count as a single order' + ), +}; + +const TX_PER_BLOCK: Stats = { + field: 'txPerBlock', + title: t('Transactions / block'), + goodThreshold: (tx: number) => tx > 2, + description: t('Number of transactions processed in the last block'), +}; + +const BLOCK_DURATION: Stats = { + field: 'blockDuration', + title: t('Block time'), + formatter: (duration: string) => { + const dp = 3; + if (duration?.includes('ms')) { + return (parseFloat(duration) / 1000).toFixed(dp).toString(); + } else if (duration?.includes('s')) { + return parseFloat(duration).toFixed(dp).toString(); + } + return duration ? (Number(duration) / 1000000000).toFixed(dp) : undefined; + }, + goodThreshold: (blockDuration: string) => { + if (blockDuration?.includes('ms')) { + // we only get ms from the api if duration is less than 1s, so this + // automatically passes + return true; + } else if (blockDuration?.includes('s')) { + return parseFloat(blockDuration) <= 2; + } else { + return Number(blockDuration) > 0 && Number(blockDuration) <= 2000000000; + } + }, + description: t('Seconds between the two most recent blocks'), +}; + +const VEGA_TIME: Stats = { + field: 'vegaTime', + title: t('Time'), + formatter: (time: Date) => { + if (!time) { + return; + } + const date = new Date(time); + if (!isValidDate(date)) { + return; + } + return getTimeFormat().format(date); + }, + goodThreshold: (time: Date) => { + const diff = new Date().getTime() - new Date(time).getTime(); + return diff > 0 && diff < 5000; + }, + description: t('The time on the blockchain'), +}; + +const APP_VERSION: Stats = { + field: 'appVersion', + title: t('App'), + description: t('Vega node software version on this node'), +}; +const CHAIN_VERSION: Stats = { + field: 'chainVersion', + title: t('Tendermint'), + description: t('Tendermint software version on this node'), +}; + +const UPTIME: Stats = { + field: 'genesisTime', + title: t('Uptime'), + formatter: (t: string) => { + if (!t) { + return '-'; + } + const date = new Date(t); + if (!isValidDate(date)) { + return '-'; + } + const secSinceStart = (new Date().getTime() - date.getTime()) / 1000; + const days = Math.floor(secSinceStart / 60 / 60 / 24); + const hours = Math.floor((secSinceStart / 60 / 60) % 24); + const mins = Math.floor((secSinceStart / 60) % 60); + const secs = Math.floor(secSinceStart % 60); + return `${days}d ${hours}h ${mins}m ${secs}s`; + }, + description: t('Time since genesis'), +}; + +const UP_SINCE: Stats = { + field: 'genesisTime', + title: t('Up since'), + formatter: (t: string) => { + if (!t) { + return '-'; + } + const date = new Date(t); + if (!isValidDate(date)) { + return '-'; + } + return getDateTimeFormat().format(date) || '-'; + }, + description: t('Genesis'), +}; + +const CHAIN_ID: Stats = { + field: 'chainId', + title: t('Chain ID'), + description: t('Identifier'), +}; + +export const fieldsDefinition: Stats[] = [ + STATUS, + BLOCK_HEIGHT, + UPTIME, + TOTAL_NODES, + TOTAL_STAKED, + BACKLOG_LENGTH, + TRADES_PER_SECOND, + AVERAGE_ORDERS_PER_BLOCK, + ORDERS_PER_SECOND, + TX_PER_BLOCK, + BLOCK_DURATION, + VEGA_TIME, + APP_VERSION, + CHAIN_VERSION, + UP_SINCE, + CHAIN_ID, +]; diff --git a/libs/network-stats/src/config/types.ts b/libs/network-stats/src/config/types.ts deleted file mode 100644 index e7681428d..000000000 --- a/libs/network-stats/src/config/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type * as Schema from '@vegaprotocol/types'; -import type { StatsQuery } from '../components/stats-manager/__generated__/Stats'; - -type NodeDataKeys = 'stakedTotal' | 'totalNodes' | 'inactiveNodes'; - -export type Stats = Pick & - Omit; - -// eslint-disable-next-line -export type value = any; -export type goodThreshold = (...args: value[]) => boolean; - -export interface StatFields { - title: string; - goodThreshold?: goodThreshold; - // eslint-disable-next-line - formatter?: (arg0: value) => any; - promoted?: boolean; - value?: value; - description?: string; -} - -export interface StructuredStats { - promoted: StatFields[]; - table: StatFields[]; -} diff --git a/libs/ui-toolkit/src/components/background-video/background-video.tsx b/libs/ui-toolkit/src/components/background-video/background-video.tsx index ba705413e..b71a26fdd 100644 --- a/libs/ui-toolkit/src/components/background-video/background-video.tsx +++ b/libs/ui-toolkit/src/components/background-video/background-video.tsx @@ -1,11 +1,19 @@ -export const BackgroundVideo = () => { +import classNames from 'classnames'; + +type BackgroundVideoProps = { + className?: string; +}; +export const BackgroundVideo = ({ className }: BackgroundVideoProps) => { return (