From e0bcfe7bbed000566799a5ef7844323b1d059417 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Fri, 13 May 2022 12:03:08 +0100 Subject: [PATCH] Feat/231-explorer-infinite-loading-blocks (#306) * Making a start with react-window-infinite-loader for the blocks infinite scrolling * WIP block explorer infinte scroll * WIP pairing * pairing tidying * Applied refetch url params more cleanly, moved useFetch to react-helpers lib * Add notice of new blocks created since page load, some cleanup of blocks-infinite-list.tsx component * Attempting a refresh of the 'new blocks' value in blocks-refetch.tsx * Correctly updating state based on previous value * Update libs/react-helpers/src/hooks/use-fetch.ts Co-authored-by: Matthew Russell * Update libs/react-helpers/src/hooks/use-fetch.ts Co-authored-by: Matthew Russell * Update apps/explorer/src/app/components/blocks/blocks-refetch.tsx Co-authored-by: Matthew Russell * Cleanup from convos and PR * Prettier formatting * struggling with websocket tests * struggling with websocket tests * Progress on websocket tests for blocks-refetch.tsx * Tests for blocks-refetch * Tests for blocks-infinite-list * Scroll test for blocks-infinite-list * Defined NOOP in blocks-infinite-list.tsx * Separate web sockets for each test * Separate web sockets for each test * Tweaked e2e tests to account for blocks taking longer to load * fix tests * Removed nx knowledge of empty simple-trading-app-e2e for now * mock at use fetch level instead of at fetch level * fix edge cases and add further tests * fix failing e2e tests * rename test * Update apps/explorer-e2e/src/support/pages/blocks-page.ts * Update apps/explorer-e2e/src/support/pages/blocks-page.ts * rename * test: use explicit wait for rather than times * style: remove console * test: correct env file * Revert "test: correct env file" This reverts commit d01d3cfa5e593cbb5ec06af4d3356bac842e61fa. * think env var is incorrect * correct env file * fix flakiness * add minor wait for test flakiness * longer timeout Co-authored-by: Dexter Co-authored-by: Matthew Russell --- apps/explorer-e2e/.env | 28 +-- .../src/support/pages/base-page.ts | 3 + .../src/support/pages/blocks-page.ts | 8 + apps/explorer/.env | 8 + .../src/app/components/blocks/block-data.tsx | 2 +- .../blocks/blocks-infinite-list.spec.tsx | 166 ++++++++++++++++++ .../blocks/blocks-infinite-list.tsx | 84 +++++++++ .../components/blocks/blocks-refetch.spec.tsx | 142 +++++++++++++++ .../app/components/blocks/blocks-refetch.tsx | 39 +++- .../src/app/components/header/header.tsx | 2 +- .../src/app/components/txs/txs-per-block.tsx | 3 +- apps/explorer/src/app/hooks/use-fetch.tsx | 97 ---------- .../src/app/routes/blocks/home/index.tsx | 117 ++++++++++-- .../src/app/routes/blocks/id/block.spec.tsx | 69 ++++++-- .../src/app/routes/blocks/id/block.tsx | 16 +- .../explorer/src/app/routes/genesis/index.tsx | 3 +- .../src/app/routes/parties/id/index.tsx | 3 +- .../explorer/src/app/routes/pending/index.tsx | 3 +- .../src/app/routes/txs/home/index.tsx | 3 +- apps/explorer/src/app/routes/txs/id/index.tsx | 2 +- .../src/app/routes/validators/index.tsx | 2 +- apps/explorer/tsconfig.json | 3 +- apps/simple-trading-app-e2e/.env | 26 +++ .../src/integration/app.test.ts | 7 + apps/token/src/hooks/use-fetch.ts | 95 ---------- .../proposal/proposal-container.tsx | 2 +- .../src/lib/commands/get-by-test-id.ts | 5 +- libs/react-helpers/src/hooks/index.ts | 1 + libs/react-helpers/src/hooks/use-fetch.ts | 117 ++++++++++++ package.json | 5 + yarn.lock | 48 ++++- 31 files changed, 838 insertions(+), 271 deletions(-) create mode 100644 apps/explorer/src/app/components/blocks/blocks-infinite-list.spec.tsx create mode 100644 apps/explorer/src/app/components/blocks/blocks-infinite-list.tsx create mode 100644 apps/explorer/src/app/components/blocks/blocks-refetch.spec.tsx delete mode 100644 apps/explorer/src/app/hooks/use-fetch.tsx create mode 100644 apps/simple-trading-app-e2e/.env create mode 100644 apps/simple-trading-app-e2e/src/integration/app.test.ts delete mode 100644 apps/token/src/hooks/use-fetch.ts create mode 100644 libs/react-helpers/src/hooks/use-fetch.ts diff --git a/apps/explorer-e2e/.env b/apps/explorer-e2e/.env index d1155e940..a4ede0103 100644 --- a/apps/explorer-e2e/.env +++ b/apps/explorer-e2e/.env @@ -1,14 +1,16 @@ -NX_CHAIN_EXPLORER_URL = "https://explorer.vega.trading/.netlify/functions/chain-explorer-api" -NX_TENDERMINT_URL = "https://lb.testnet.vega.xyz/tm" -NX_TENDERMINT_WEBSOCKET_URL = "wss://lb.testnet.vega.xyz/tm/websocket" -NX_VEGA_URL = "https://lb.testnet.vega.xyz/query" -NX_VEGA_ENV = 'TESTNET' -NX_VEGA_REST = 'https://lb.testnet.vega.xyz/datanode/rest' +NX_CHAIN_EXPLORER_URL="https://explorer.vega.trading/.netlify/functions/chain-explorer-api" +NX_TENDERMINT_URL="https://lb.testnet.vega.xyz/tm" +NX_TENDERMINT_WEBSOCKET_URL="wss://lb.testnet.vega.xyz/tm/websocket" +NX_VEGA_URL="https://lb.testnet.vega.xyz/query" +NX_VEGA_ENV='TESTNET' +NX_VEGA_REST='https://lb.testnet.vega.xyz/datanode/rest' -NX_EXPLORER_ASSETS = 1 -NX_EXPLORER_GENESIS = 1 -NX_EXPLORER_GOVERNANCE = 1 -NX_EXPLORER_MARKETS = 1 -NX_EXPLORER_NETWORK_PARAMETERS = 1 -NX_EXPLORER_PARTIES = 1 -NX_EXPLORER_VALIDATORS = 1 +CYPRESS_VEGA_TENDERMINT_URL='https://lb.testnet.vega.xyz/tm' + +NX_EXPLORER_ASSETS=1 +NX_EXPLORER_GENESIS=1 +NX_EXPLORER_GOVERNANCE=1 +NX_EXPLORER_MARKETS=1 +NX_EXPLORER_NETWORK_PARAMETERS=1 +NX_EXPLORER_PARTIES=1 +NX_EXPLORER_VALIDATORS=1 diff --git a/apps/explorer-e2e/src/support/pages/base-page.ts b/apps/explorer-e2e/src/support/pages/base-page.ts index 7c63641e6..3514fc8f5 100644 --- a/apps/explorer-e2e/src/support/pages/base-page.ts +++ b/apps/explorer-e2e/src/support/pages/base-page.ts @@ -19,6 +19,9 @@ export default class BasePage { } navigateToBlocks() { + const base = Cypress.env('VEGA_TENDERMINT_URL'); + const url = new URL('/tm/blockchain*', base).toString(); + cy.intercept(url).as('blockChain'); cy.get(`a[href='${this.blocksUrl}']`).click(); } diff --git a/apps/explorer-e2e/src/support/pages/blocks-page.ts b/apps/explorer-e2e/src/support/pages/blocks-page.ts index 46a41e239..22fa7d1b6 100644 --- a/apps/explorer-e2e/src/support/pages/blocks-page.ts +++ b/apps/explorer-e2e/src/support/pages/blocks-page.ts @@ -15,7 +15,14 @@ export default class BlocksPage extends BasePage { jumpToBlockInput = 'block-input'; jumpToBlockSubmit = 'go-submit'; + private waitForBlocksResponse() { + cy.wait('@blockChain'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + } + validateBlocksPageDisplayed() { + this.waitForBlocksResponse(); cy.getByTestId(this.blockRow).should('have.length.above', 1); cy.getByTestId(this.blockHeight).first().should('not.be.empty'); cy.getByTestId(this.numberOfTransactions).first().should('not.be.empty'); @@ -24,6 +31,7 @@ export default class BlocksPage extends BasePage { } clickOnTopBlockHeight() { + this.waitForBlocksResponse(); cy.getByTestId(this.blockHeight).first().click(); } diff --git a/apps/explorer/.env b/apps/explorer/.env index 123a0c798..ae45130a1 100644 --- a/apps/explorer/.env +++ b/apps/explorer/.env @@ -18,6 +18,14 @@ NX_URL=$URL NX_DEPLOY_URL=$DEPLOY_URL NX_DEPLOY_PRIME_URL=$DEPLOY_PRIME_URL +NX_CHAIN_EXPLORER_URL = "https://explorer.vega.trading/.netlify/functions/chain-explorer-api" +NX_TENDERMINT_URL = "https://lb.testnet.vega.xyz/tm" +NX_TENDERMINT_WEBSOCKET_URL = "wss://lb.testnet.vega.xyz/tm/websocket" +NX_VEGA_URL = "https://lb.testnet.vega.xyz/query" +NX_VEGA_ENV = 'TESTNET' +NX_VEGA_REST = 'https://lb.testnet.vega.xyz/datanode/rest' +CYPRESS_VEGA_TENDERMINT_URL='https://lb.testnet.vega.xyz/tm' + # App flags NX_EXPLORER_ASSETS = 1 NX_EXPLORER_GENESIS = 1 diff --git a/apps/explorer/src/app/components/blocks/block-data.tsx b/apps/explorer/src/app/components/blocks/block-data.tsx index 565d40e1e..b392892e3 100644 --- a/apps/explorer/src/app/components/blocks/block-data.tsx +++ b/apps/explorer/src/app/components/blocks/block-data.tsx @@ -15,7 +15,7 @@ export const BlockData = ({ block, className }: BlockProps) => { return ( { + return Array.from(Array(number)).map((_) => ({ + block_id: { + hash: '1C4D61AD80C250713EB996FB19B4537283083FB1878A98DA620872EE17A2103A', + parts: { + total: 1, + hash: '75A26A06BAA518A94B873229BF3A6DC5DD376A588C476B6416E181CF07F36FA1', + }, + }, + block_size: '1965', + header: { + version: { + block: '11', + }, + chain_id: 'vega-mainnet-0006', + height: '4993074', + time: '2022-05-09T12:14:31.021164227Z', + last_block_id: { + hash: '3EA62A0EF7B668E1522EE06DC5395E2B8DE31B621C707B68D05F8AA66BD168EE', + parts: { + total: 1, + hash: '5B3CD9883FFA5282FB46B0A2D7C587FDCEF2DC1660DF105B6D67649A21AEF3C7', + }, + }, + last_commit_hash: + '2CDC8B12074A5A4FD67B95CEB573BFC1E91A58D2E839E03B2B3FC94214100D04', + data_hash: + 'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855', + validators_hash: + 'C3F329FFD2DC0B5F51D6100669BAE705647D0ED016B82AFADE38AA1723ABE9D9', + next_validators_hash: + 'C3F329FFD2DC0B5F51D6100669BAE705647D0ED016B82AFADE38AA1723ABE9D9', + consensus_hash: + '048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F', + app_hash: + 'A7FFC6F8BF1ED76651C14756A061D662F580FF4DE43B49FA82D80A4B80F8434A3237DE294EFA38CE03133F411BFE0CEE5D6948CEA838B57C475143E2BF3A3D0CA1292C11CCDB876535C6699E8217E1A1294190D83E4233ECC490D32DF17A4116ADB0E5863D3F5309179F94FD63634116788C827BE5C6163F12DA68FAA77261CD', + last_results_hash: + 'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855', + evidence_hash: + 'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855', + proposer_address: '53D53755DA3B815AB029C0062DD6A2B392E640DC', + }, + num_txs: '0', + })); +}; + +describe('Blocks infinite list', () => { + it('should display a "no items" message when no items provided', () => { + render( + null} + error={undefined} + /> + ); + expect(screen.getByText('No items')).toBeInTheDocument(); + }); + + it('error is displayed at item level', () => { + const blocks = generateBlocks(1); + render( + null} + error={Error('test error!')} + /> + ); + expect(screen.getByText('Error: test error!')).toBeInTheDocument(); + }); + + it('item renders data of n length into list of n length', () => { + // Provided the number of items doesn't exceed the 20 it initially + // desires, all blocks will initially render + const blocks = generateBlocks(10); + render( + + null} + error={undefined} + /> + + ); + + expect( + screen + .getByTestId('infinite-scroll-wrapper') + .querySelectorAll('.block-data-table') + ).toHaveLength(10); + }); + + it('tries to load more items when required to initially fill the list', () => { + // For example, if initially rendering 15, the bottom of the list is + // in view of the viewport, and the callback should be executed + const blocks = generateBlocks(15); + const callback = jest.fn(); + + render( + + + + ); + + expect(callback.mock.calls.length).toEqual(1); + }); + + it('does not try to load more items if there are no more', () => { + const blocks = generateBlocks(3); + const callback = jest.fn(); + + render( + + + + ); + + expect(callback.mock.calls.length).toEqual(0); + }); + + it('loads more items is called when scrolled', () => { + const blocks = generateBlocks(20); + const callback = jest.fn(); + + render( + + + + ); + + act(() => { + fireEvent.scroll(screen.getByTestId('infinite-scroll-wrapper'), { + target: { scrollY: 600 }, + }); + }); + + expect(callback.mock.calls.length).toEqual(1); + }); +}); diff --git a/apps/explorer/src/app/components/blocks/blocks-infinite-list.tsx b/apps/explorer/src/app/components/blocks/blocks-infinite-list.tsx new file mode 100644 index 000000000..a06c60a27 --- /dev/null +++ b/apps/explorer/src/app/components/blocks/blocks-infinite-list.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { FixedSizeList as List } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; +import { t } from '@vegaprotocol/react-helpers'; +import type { BlockMeta } from '../../routes/blocks/tendermint-blockchain-response'; +import { BlockData } from './block-data'; + +interface BlocksInfiniteListProps { + hasMoreBlocks: boolean; + areBlocksLoading: boolean | undefined; + blocks: BlockMeta[] | undefined; + loadMoreBlocks: () => void; + error: Error | undefined; + className?: string; +} + +interface ItemProps { + index: number; + style: React.CSSProperties; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const NOOP = () => {}; + +export const BlocksInfiniteList = ({ + hasMoreBlocks, + areBlocksLoading, + blocks, + loadMoreBlocks, + error, + className, +}: BlocksInfiniteListProps) => { + if (!blocks) { + return
No items
; + } + + // If there are more items to be loaded then add an extra row to hold a loading indicator. + const itemCount = hasMoreBlocks ? blocks.length + 1 : blocks.length; + + // Pass an empty callback to InfiniteLoader in case it asks us to load more than once. + // eslint-disable-next-line @typescript-eslint/no-empty-function + const loadMoreItems = areBlocksLoading ? NOOP : loadMoreBlocks; + + // Every row is loaded except for our loading indicator row. + const isItemLoaded = (index: number) => + !hasMoreBlocks || index < blocks.length; + + const Item = ({ index, style }: ItemProps) => { + let content; + if (error) { + content = t(`${error}`); + } else if (!isItemLoaded(index)) { + content = t('Loading...'); + } else { + content = ; + } + + return
{content}
; + }; + + return ( +
+ + {({ onItemsRendered, ref }) => ( + + {Item} + + )} + +
+ ); +}; diff --git a/apps/explorer/src/app/components/blocks/blocks-refetch.spec.tsx b/apps/explorer/src/app/components/blocks/blocks-refetch.spec.tsx new file mode 100644 index 000000000..2dfda1081 --- /dev/null +++ b/apps/explorer/src/app/components/blocks/blocks-refetch.spec.tsx @@ -0,0 +1,142 @@ +import WS from 'jest-websocket-mock'; +import useWebSocket from 'react-use-websocket'; +import { + render, + screen, + fireEvent, + act, + waitFor, +} from '@testing-library/react'; +import { TendermintWebsocketContext } from '../../contexts/websocket/tendermint-websocket-context'; +import { BlocksRefetch } from './blocks-refetch'; + +const BlocksRefetchInWebsocketProvider = ({ + callback, + mocketLocation, +}: { + callback: () => null; + mocketLocation: string; +}) => { + const contextShape = useWebSocket(mocketLocation); + + return ( + + + + ); +}; + +describe('Blocks refetch', () => { + it('should render inner components', async () => { + const mocketLocation = 'wss:localhost:3002'; + const mocket = new WS(mocketLocation, { jsonProtocol: true }); + new WebSocket(mocketLocation); + + render( + null} + mocketLocation={mocketLocation} + /> + ); + await mocket.connected; + expect(screen.getByTestId('new-blocks')).toHaveTextContent('new blocks'); + expect(screen.getByTestId('refresh')).toBeInTheDocument(); + mocket.close(); + }); + + it('should initiate callback when the button is clicked', async () => { + const mocketLocation = 'wss:localhost:3003'; + const mocket = new WS(mocketLocation, { jsonProtocol: true }); + new WebSocket(mocketLocation); + + const callback = jest.fn(); + render( + + ); + await mocket.connected; + const button = screen.getByTestId('refresh'); + + act(() => { + fireEvent.click(button); + }); + + expect(callback.mock.calls.length).toEqual(1); + mocket.close(); + }); + + it('should show new blocks as websocket is correctly updated', async () => { + const mocketLocation = 'wss:localhost:3004'; + const mocket = new WS(mocketLocation, { jsonProtocol: true }); + new WebSocket(mocketLocation); + render( + null} + mocketLocation={mocketLocation} + /> + ); + await mocket.connected; + + // Ensuring we send an ID equal to the one the client subscribed with. + await waitFor(() => expect(mocket.messages.length).toEqual(1)); + // @ts-ignore id on messages + const id = mocket.messages[0].id; + + const newBlockMessage = { + id, + result: { + query: "tm.event = 'NewBlock'", + }, + }; + + expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks'); + + act(() => { + mocket.send(newBlockMessage); + }); + + expect(screen.getByTestId('new-blocks')).toHaveTextContent('1 new blocks'); + + act(() => { + mocket.send(newBlockMessage); + }); + + expect(screen.getByTestId('new-blocks')).toHaveTextContent('2 new blocks'); + mocket.close(); + }); + + it('will not show new blocks if websocket has wrong ID', async () => { + const mocketLocation = 'wss:localhost:3005'; + const mocket = new WS(mocketLocation, { jsonProtocol: true }); + new WebSocket(mocketLocation); + + render( + null} + mocketLocation={mocketLocation} + /> + ); + await mocket.connected; + + // Ensuring we send an ID equal to the one the client subscribed with. + await waitFor(() => expect(mocket.messages.length).toEqual(1)); + + const newBlockMessageBadId = { + id: 'blahblahblah', + result: { + query: "tm.event = 'NewBlock'", + }, + }; + + expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks'); + + act(() => { + mocket.send(newBlockMessageBadId); + }); + + expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks'); + mocket.close(); + }); +}); diff --git a/apps/explorer/src/app/components/blocks/blocks-refetch.tsx b/apps/explorer/src/app/components/blocks/blocks-refetch.tsx index 18e6f5177..c61b98b7d 100644 --- a/apps/explorer/src/app/components/blocks/blocks-refetch.tsx +++ b/apps/explorer/src/app/components/blocks/blocks-refetch.tsx @@ -1,3 +1,5 @@ +import { useState, useEffect } from 'react'; +import { useTendermintWebsocket } from '../../hooks/use-tendermint-websocket'; import { t } from '@vegaprotocol/react-helpers'; import { Button } from '@vegaprotocol/ui-toolkit'; @@ -6,14 +8,35 @@ interface BlocksRefetchProps { } export const BlocksRefetch = ({ refetch }: BlocksRefetchProps) => { + const [blocksToLoad, setBlocksToLoad] = useState(0); + + const { messages } = useTendermintWebsocket({ + query: "tm.event = 'NewBlock'", + }); + + useEffect(() => { + if (messages.length > 0) { + setBlocksToLoad((prev) => prev + 1); + } + }, [messages]); + + const refresh = () => { + refetch(); + setBlocksToLoad(0); + }; + return ( - + <> + {blocksToLoad} new blocks - + + + ); }; diff --git a/apps/explorer/src/app/components/header/header.tsx b/apps/explorer/src/app/components/header/header.tsx index 1bcecaf54..1a002538e 100644 --- a/apps/explorer/src/app/components/header/header.tsx +++ b/apps/explorer/src/app/components/header/header.tsx @@ -20,7 +20,7 @@ export const Header = ({

{t('Vega Explorer')} diff --git a/apps/explorer/src/app/components/txs/txs-per-block.tsx b/apps/explorer/src/app/components/txs/txs-per-block.tsx index 6e4ed8e1d..3d4bb3b2e 100644 --- a/apps/explorer/src/app/components/txs/txs-per-block.tsx +++ b/apps/explorer/src/app/components/txs/txs-per-block.tsx @@ -1,4 +1,3 @@ -import useFetch from '../../hooks/use-fetch'; import type { ChainExplorerTxResponse } from '../../routes/types/chain-explorer-response'; import { Routes } from '../../routes/route-names'; import { DATA_SOURCES } from '../../config'; @@ -6,7 +5,7 @@ import { RenderFetched } from '../render-fetched'; import { TruncatedLink } from '../truncate/truncated-link'; import { TxOrderType } from './tx-order-type'; import { Table, TableRow, TableCell } from '../table'; -import { t } from '@vegaprotocol/react-helpers'; +import { t, useFetch } from '@vegaprotocol/react-helpers'; interface TxsPerBlockProps { blockHeight: string | undefined; diff --git a/apps/explorer/src/app/hooks/use-fetch.tsx b/apps/explorer/src/app/hooks/use-fetch.tsx deleted file mode 100644 index 94945ebab..000000000 --- a/apps/explorer/src/app/hooks/use-fetch.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useCallback, useEffect, useReducer, useRef } from 'react'; - -interface State { - data?: T; - error?: Error; - loading?: boolean; -} - -enum ActionType { - LOADING = 'LOADING', - ERROR = 'ERROR', - FETCHED = 'FETCHED', -} - -// discriminated union type -type Action = - | { type: ActionType.LOADING } - | { type: ActionType.FETCHED; payload: T } - | { type: ActionType.ERROR; error: Error }; - -function useFetch( - url: string, - options?: RequestInit -): { state: State; refetch: () => void } { - // Used to prevent state update if the component is unmounted - const cancelRequest = useRef<{ [key: string]: boolean }>({ [url]: false }); - - const initialState: State = { - error: undefined, - data: undefined, - loading: false, - }; - - // Keep state logic separated - const fetchReducer = (state: State, action: Action): State => { - switch (action.type) { - case ActionType.LOADING: - return { ...initialState, loading: true }; - case ActionType.FETCHED: - return { ...initialState, data: action.payload, loading: false }; - case ActionType.ERROR: - return { ...initialState, error: action.error, loading: false }; - } - }; - - const [state, dispatch] = useReducer(fetchReducer, initialState); - const fetchCallback = useCallback(() => { - if (!url) return; - - const fetchData = async () => { - dispatch({ type: ActionType.LOADING }); - - try { - const response = await fetch(url, options); - if (!response.ok) { - throw new Error(response.statusText); - } - - const data = (await response.json()) as T; - if (data && 'error' in data) { - // @ts-ignore - data.error - throw new Error(data.error); - } - if (cancelRequest.current[url]) return; - - dispatch({ type: ActionType.FETCHED, payload: data }); - } catch (error) { - if (cancelRequest.current[url]) return; - - dispatch({ type: ActionType.ERROR, error: error as Error }); - } - }; - - void fetchData(); - - // Do nothing if the url is not given - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [url]); - - useEffect(() => { - const cancel = cancelRequest.current; - cancel[url] = false; - fetchCallback(); - // Use the cleanup function for avoiding a possibly... - // ...state update after the component was unmounted - return () => { - cancel[url] = true; - }; - }, [fetchCallback, url]); - - return { - state, - refetch: fetchCallback, - }; -} - -export default useFetch; diff --git a/apps/explorer/src/app/routes/blocks/home/index.tsx b/apps/explorer/src/app/routes/blocks/home/index.tsx index 99ec64fcc..cb56726aa 100644 --- a/apps/explorer/src/app/routes/blocks/home/index.tsx +++ b/apps/explorer/src/app/routes/blocks/home/index.tsx @@ -1,29 +1,120 @@ +import { useCallback, useState } from 'react'; import { DATA_SOURCES } from '../../../config'; -import useFetch from '../../../hooks/use-fetch'; -import type { TendermintBlockchainResponse } from '../tendermint-blockchain-response'; +import type { + BlockMeta, + TendermintBlockchainResponse, +} from '../tendermint-blockchain-response'; import { RouteTitle } from '../../../components/route-title'; -import { RenderFetched } from '../../../components/render-fetched'; -import { BlocksData, BlocksRefetch } from '../../../components/blocks'; +import { BlocksRefetch } from '../../../components/blocks'; +import { BlocksInfiniteList } from '../../../components/blocks/blocks-infinite-list'; import { JumpToBlock } from '../../../components/jump-to-block'; -import { t } from '@vegaprotocol/react-helpers'; +import { t, useFetch } from '@vegaprotocol/react-helpers'; + +// This constant should only be changed if Tendermint API changes the max blocks returned +const TM_BLOCKS_PER_REQUEST = 20; + +interface BlocksStateProps { + areBlocksLoading: boolean | undefined; + blocksError: Error | undefined; + blocksData: BlockMeta[]; + hasMoreBlocks: boolean; + nextBlockHeightToLoad: number | undefined; +} const Blocks = () => { + const [ + { + areBlocksLoading, + blocksError, + blocksData, + hasMoreBlocks, + nextBlockHeightToLoad, + }, + setBlocksState, + ] = useState({ + areBlocksLoading: false, + blocksError: undefined, + blocksData: [], + hasMoreBlocks: true, + nextBlockHeightToLoad: undefined, + }); + const { - state: { data, error, loading }, + state: { error, loading }, refetch, } = useFetch( - `${DATA_SOURCES.tendermintUrl}/blockchain` + `${DATA_SOURCES.tendermintUrl}/blockchain`, + undefined, + false ); + const loadBlocks = useCallback(async () => { + setBlocksState((prev) => ({ + ...prev, + areBlocksLoading: loading, + })); + + const maxHeight = Math.max( + Number(nextBlockHeightToLoad), + TM_BLOCKS_PER_REQUEST + ); + + const minHeight = + Number(nextBlockHeightToLoad) - TM_BLOCKS_PER_REQUEST > 1 + ? Number(nextBlockHeightToLoad) - TM_BLOCKS_PER_REQUEST - 1 + : undefined; + + const data = await refetch({ + maxHeight, + minHeight, + }); + + if (data) { + const blockMetas = data.result.block_metas; + const lastBlockHeightLoaded = + blockMetas && blockMetas.length > 0 + ? parseInt(blockMetas[blockMetas.length - 1].header.height) + : undefined; + + setBlocksState((prev) => ({ + ...prev, + nextBlockHeightToLoad: lastBlockHeightLoaded + ? lastBlockHeightLoaded - 1 + : undefined, + hasMoreBlocks: !!lastBlockHeightLoaded && lastBlockHeightLoaded > 1, + blocksData: [...prev.blocksData, ...blockMetas], + })); + } + + if (error) { + setBlocksState((prev) => ({ + ...prev, + blocksError: error, + })); + } + }, [error, loading, nextBlockHeightToLoad, refetch]); + + const refreshBlocks = useCallback(async () => { + setBlocksState((prev) => ({ + ...prev, + nextBlockHeightToLoad: undefined, + hasMoreBlocks: true, + blocksData: [], + })); + }, [setBlocksState]); + return (
{t('Blocks')} - - <> - - - - + +
); diff --git a/apps/explorer/src/app/routes/blocks/id/block.spec.tsx b/apps/explorer/src/app/routes/blocks/id/block.spec.tsx index 031490766..c3cf88cbb 100644 --- a/apps/explorer/src/app/routes/blocks/id/block.spec.tsx +++ b/apps/explorer/src/app/routes/blocks/id/block.spec.tsx @@ -2,10 +2,19 @@ import { Block } from './block'; import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { Routes as RouteNames } from '../../route-names'; +import { useFetch } from '@vegaprotocol/react-helpers'; + +jest.mock('@vegaprotocol/react-helpers', () => { + const original = jest.requireActual('@vegaprotocol/react-helpers'); + return { + ...original, + useFetch: jest.fn(), + }; +}); const blockId = 1085890; -const createBlockResponse = () => { +const createBlockResponse = (id: number = blockId) => { return { jsonrpc: '2.0', id: -1, @@ -58,7 +67,7 @@ const createBlockResponse = () => { evidence: [], }, last_commit: { - height: blockId.toString(), + height: id.toString(), round: 0, block_id: { hash: 'C50CA169545AC1280220433D7971C50D941F675E9B0FFF358ABE8F3A7F74AE0E', @@ -110,9 +119,9 @@ const createBlockResponse = () => { }; }; -const renderComponent = () => { +const renderComponent = (id: number = blockId) => { return ( - + } /> @@ -121,31 +130,41 @@ const renderComponent = () => { }; beforeEach(() => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(createBlockResponse()), - }) - ) as jest.Mock; jest.useFakeTimers().setSystemTime(1648123348642); }); afterEach(() => { - jest.useRealTimers(); jest.clearAllMocks(); + jest.useRealTimers(); }); describe('Block', () => { + it('renders error state if error is present', async () => { + (useFetch as jest.Mock).mockReturnValue({ + state: { data: null, loading: false, error: 'asd' }, + }); + render(renderComponent()); + + expect(screen.getByText(`BLOCK ${blockId}`)).toBeInTheDocument(); + expect(screen.getByText('Error retrieving data')).toBeInTheDocument(); + }); + + it('renders loading state if present', async () => { + (useFetch as jest.Mock).mockReturnValue({ + state: { data: null, loading: true, error: null }, + }); + render(renderComponent()); + + expect(screen.getByText(`BLOCK ${blockId}`)).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + it('should render title, proposer address and time mined', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(createBlockResponse()), - }) - ) as jest.Mock; + (useFetch as jest.Mock).mockReturnValue({ + state: { data: createBlockResponse(), loading: false, error: null }, + }); render(renderComponent()); await waitFor(() => screen.getByTestId('block-header')); - expect(screen.getByTestId('block-header')).toHaveTextContent( `BLOCK ${blockId}` ); @@ -160,6 +179,9 @@ describe('Block', () => { }); it('renders next and previous buttons', async () => { + (useFetch as jest.Mock).mockReturnValue({ + state: { data: createBlockResponse(), loading: false, error: null }, + }); render(renderComponent()); await waitFor(() => screen.getByTestId('block-header')); @@ -172,4 +194,15 @@ describe('Block', () => { `/${RouteNames.BLOCKS}/${blockId + 1}` ); }); + + it('disables previous button on block 1', async () => { + (useFetch as jest.Mock).mockReturnValue({ + state: { data: createBlockResponse(1), loading: false, error: null }, + }); + render(renderComponent(1)); + await waitFor(() => screen.getByTestId('block-header')); + expect(screen.getByTestId('previous-block-button')).toHaveAttribute( + 'disabled' + ); + }); }); diff --git a/apps/explorer/src/app/routes/blocks/id/block.tsx b/apps/explorer/src/app/routes/blocks/id/block.tsx index 83fd2355a..f0b2244f4 100644 --- a/apps/explorer/src/app/routes/blocks/id/block.tsx +++ b/apps/explorer/src/app/routes/blocks/id/block.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Link, useParams } from 'react-router-dom'; import { DATA_SOURCES } from '../../../config'; -import useFetch from '../../../hooks/use-fetch'; import type { TendermintBlocksResponse } from '../tendermint-blocks-response'; import { RouteTitle } from '../../../components/route-title'; import { SecondsAgo } from '../../../components/seconds-ago'; @@ -16,7 +15,7 @@ import { Button } from '@vegaprotocol/ui-toolkit'; import { Routes } from '../../route-names'; import { RenderFetched } from '../../../components/render-fetched'; import { HighlightedLink } from '../../../components/highlighted-link'; -import { t } from '@vegaprotocol/react-helpers'; +import { t, useFetch } from '@vegaprotocol/react-helpers'; const Block = () => { const { block } = useParams<{ block: string }>(); @@ -26,11 +25,6 @@ const Block = () => { `${DATA_SOURCES.tendermintUrl}/block?height=${block}` ); - const header = blockData?.result.block.header; - if (!header) { - return

{t('Could not get block data')}

; - } - return (
{t(`BLOCK ${block}`)} @@ -42,6 +36,7 @@ const Block = () => { to={`/${Routes.BLOCKS}/${Number(block) - 1}`} >