From 2094a29d35303861431e38138b553c65ce3af4a3 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Wed, 29 Jun 2022 11:20:22 +0100 Subject: [PATCH] Feat/397 - continuous txs list (#417) * frontend-monorepo-397 - continuous txs list - glitchy start * frontend-monorepo-397 - experimentation! * fix: transactions infinite list loading * fix: blocks reload * frontend-monorepo-397 - removed redundant renderFetched component from infinite txs page, and removed debugging * frontend-monorepo-397 - tests written, added button that opens a dialog with extra Command data to each list item * frontend-monorepo-397 - Cleaned up styling of txs list a bit, addressed PR comments. * frontend-monorepo-397 - tweaks to e2e tests * frontend-monorepo-397 - disabling txs e2e tests for now * fix: use fetch hook * Update apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx Co-authored-by: Dexter Edwards * frontend-monorepo-397 - refactored to use AsyncRenderer which now supports custom messaging * frontend-monorepo-397 Continuous txs: set ignore for timing out tests rather than commenting out * frontend-monorepo-397: Updated txs-infinite-list-item.tsx to work with the new dialog changes * frontend-monorepo-397: Ignoring txs page e2e tests properly Co-authored-by: Dexter --- .../src/integration/transactions-page.feature | 4 +- .../src/support/pages/transactions-page.ts | 8 +- .../explorer/src/app/components/txs/index.tsx | 2 + .../txs/txs-infinite-list-item.spec.tsx | 92 ++++++++++++ .../components/txs/txs-infinite-list-item.tsx | 70 +++++++++ .../components/txs/txs-infinite-list.spec.tsx | 134 ++++++++++++++++++ .../app/components/txs/txs-infinite-list.tsx | 109 ++++++++++++++ .../explorer/src/app/routes/router-config.tsx | 4 +- .../src/app/routes/txs/home/index.tsx | 124 +++++++++++++--- libs/react-helpers/src/hooks/use-fetch.ts | 13 +- .../async-renderer/async-renderer.tsx | 18 ++- 11 files changed, 546 insertions(+), 32 deletions(-) create mode 100644 apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx create mode 100644 apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/txs-infinite-list.tsx diff --git a/apps/explorer-e2e/src/integration/transactions-page.feature b/apps/explorer-e2e/src/integration/transactions-page.feature index 0eb3bc1a2..edd8709ac 100644 --- a/apps/explorer-e2e/src/integration/transactions-page.feature +++ b/apps/explorer-e2e/src/integration/transactions-page.feature @@ -1,3 +1,5 @@ +@ignore +# tendermint times out getting txs on testnet atm Feature: Transactions Page Scenario: Navigate to transactions page @@ -5,14 +7,12 @@ Feature: Transactions Page When I navigate to the transactions page Then transactions page is correctly displayed - @ignore Scenario: Navigate to transaction details page Given I am on the homepage When I navigate to the transactions page And I click on the top transaction Then transaction details are displayed - @ignore Scenario: Navigate to transactions page using mobile Given I am on mobile and open the toggle menu When I navigate to the transactions page diff --git a/apps/explorer-e2e/src/support/pages/transactions-page.ts b/apps/explorer-e2e/src/support/pages/transactions-page.ts index ad0c7f0c4..ab072e208 100644 --- a/apps/explorer-e2e/src/support/pages/transactions-page.ts +++ b/apps/explorer-e2e/src/support/pages/transactions-page.ts @@ -15,11 +15,9 @@ export default class TransactionsPage extends BasePage { txType = 'tx-type'; validateTransactionsPagedisplayed() { - cy.getByTestId(this.transactionsList).should('have.length.above', 1); - cy.getByTestId(this.blockHeight).first().should('not.be.empty'); - cy.getByTestId(this.numberOfTransactions).first().should('not.be.empty'); - cy.getByTestId(this.validatorLink).first().should('not.be.empty'); - cy.getByTestId(this.blockTime).first().should('not.be.empty'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(5000); // Wait for transactions to load if there are any + cy.getByTestId(this.transactionRow).should('have.length.above', 1); } validateRefreshBtn() { diff --git a/apps/explorer/src/app/components/txs/index.tsx b/apps/explorer/src/app/components/txs/index.tsx index bd8d26d32..74e28c13c 100644 --- a/apps/explorer/src/app/components/txs/index.tsx +++ b/apps/explorer/src/app/components/txs/index.tsx @@ -1,3 +1,5 @@ export { TxList } from './tx-list'; export { BlockTxsData } from './block-txs-data'; export { TxOrderType } from './tx-order-type'; +export { TxsInfiniteList } from './txs-infinite-list'; +export { TxsInfiniteListItem } from './txs-infinite-list-item'; diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx new file mode 100644 index 000000000..2673042e9 --- /dev/null +++ b/apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx @@ -0,0 +1,92 @@ +import { TxsInfiniteListItem } from './txs-infinite-list-item'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +describe('Txs infinite list item', () => { + it('should display "missing vital data" if "Type" data missing', () => { + render( + + ); + expect(screen.getByText('Missing vital data')).toBeInTheDocument(); + }); + + it('should display "missing vital data" if "Command" data missing', () => { + render( + + ); + expect(screen.getByText('Missing vital data')).toBeInTheDocument(); + }); + + it('should display "missing vital data" if "Pubkey" data missing', () => { + render( + + ); + expect(screen.getByText('Missing vital data')).toBeInTheDocument(); + }); + + it('should display "missing vital data" if "TxHash" data missing', () => { + render( + + ); + expect(screen.getByText('Missing vital data')).toBeInTheDocument(); + }); + + it('renders data correctly', () => { + const testCommandData = JSON.stringify({ + test: 'test of command data', + }); + + render( + + + + ); + expect(screen.getByTestId('tx-hash')).toHaveTextContent('testTxHash'); + expect(screen.getByTestId('pub-key')).toHaveTextContent('testPubKey'); + expect(screen.getByTestId('type')).toHaveTextContent('testType'); + const button = screen.getByTestId('command-details'); + act(() => { + fireEvent.click(button); + }); + expect(screen.getByText('"test of command data"')).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx new file mode 100644 index 000000000..caaad0bf7 --- /dev/null +++ b/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { + Dialog, + Icon, + Intent, + SyntaxHighlighter, +} from '@vegaprotocol/ui-toolkit'; +import { TruncatedLink } from '../truncate/truncated-link'; +import { Routes } from '../../routes/route-names'; +import { TxOrderType } from './tx-order-type'; +import type { ChainExplorerTxResponse } from '../../routes/types/chain-explorer-response'; + +const TRUNCATE_LENGTH = 14; + +export const TxsInfiniteListItem = ({ + TxHash, + PubKey, + Type, + Command, +}: ChainExplorerTxResponse) => { + const [open, setOpen] = useState(false); + + if (!TxHash || !PubKey || !Type || !Command) { + return
Missing vital data
; + } + + return ( +
+
+ +
+
+ +
+
+ + +
+
+ ); +}; diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx new file mode 100644 index 000000000..97e100cd5 --- /dev/null +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx @@ -0,0 +1,134 @@ +import { TxsInfiniteList } from './txs-infinite-list'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const generateTxs = (number: number) => { + return Array.from(Array(number)).map((_) => ({ + Type: 'ChainEvent', + Command: + '{"txId":"0xc8941ac4ea989988cb8f72e8fdab2e2009376fd17619491439d36b519d27bc93","nonce":"1494","stakingEvent":{"index":"263","block":"14805346","stakeDeposited":{"ethereumAddress":"0x2e5fe63e5d49c26998cf4bfa9b64de1cf9ae7ef2","vegaPublicKey":"657c2a8a5867c43c831e24820b7544e2fdcc1cf610cfe0ece940fe78137400fd","amount":"38471116086510047870875","blockTime":"1652968806"}}}', + Sig: 'fe7624ab742c492cf1e667e79de4777992aca8e093c8707e1f22685c3125c6082cd21b85cd966a61ad4ca0cca2f8bed3082565caa5915bc3b2f78c1ae35cac0b', + PubKey: + '0x7d69327393cdfaaae50e5e215feca65273eafabfb38f32b8124e66298af346d5', + Nonce: 18296387398179254000, + TxHash: + '0x9C753FA6325F7A40D9C4FA5C25E24476C54613E12B1FA2DD841E3BB00D088B77', + })); +}; + +describe('Txs 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 txs = generateTxs(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 30 it initially + // desires, all txs will initially render + const txs = generateTxs(10); + render( + + null} + error={undefined} + /> + + ); + + expect( + screen + .getByTestId('infinite-scroll-wrapper') + .querySelectorAll('.txs-infinite-list-item') + ).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 txs = generateTxs(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 txs = generateTxs(3); + const callback = jest.fn(); + + render( + + + + ); + + expect(callback.mock.calls.length).toEqual(0); + }); + + it('loads more items is called when scrolled', () => { + const txs = generateTxs(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/txs/txs-infinite-list.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx new file mode 100644 index 000000000..6a8e9b271 --- /dev/null +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx @@ -0,0 +1,109 @@ +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 { TxsInfiniteListItem } from './txs-infinite-list-item'; +import type { ChainExplorerTxResponse } from '../../routes/types/chain-explorer-response'; + +interface TxsInfiniteListProps { + hasMoreTxs: boolean; + areTxsLoading: boolean | undefined; + txs: ChainExplorerTxResponse[] | undefined; + loadMoreTxs: () => void; + error: Error | undefined; + className?: string; +} + +interface ItemProps { + index: ChainExplorerTxResponse; + style: React.CSSProperties; + isLoading: boolean; + error: Error | undefined; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const NOOP = () => {}; + +const Item = ({ index, style, isLoading, error }: ItemProps) => { + let content; + if (error) { + content = t(`${error}`); + } else if (isLoading) { + content = t('Loading...'); + } else { + const { TxHash, PubKey, Type, Command, Sig, Nonce } = index; + content = ( + + ); + } + + return
{content}
; +}; + +export const TxsInfiniteList = ({ + hasMoreTxs, + areTxsLoading, + txs, + loadMoreTxs, + error, + className, +}: TxsInfiniteListProps) => { + if (!txs) { + return
No items
; + } + + // If there are more items to be loaded then add an extra row to hold a loading indicator. + const itemCount = hasMoreTxs ? txs.length + 1 : txs.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 = areTxsLoading ? NOOP : loadMoreTxs; + + // Every row is loaded except for our loading indicator row. + const isItemLoaded = (index: number) => !hasMoreTxs || index < txs.length; + + return ( +
+
+
Txn hash
+
Party
+
Type
+
+
+ + {({ onItemsRendered, ref }) => ( + + {({ index, style }) => ( + + )} + + )} + +
+
+ ); +}; diff --git a/apps/explorer/src/app/routes/router-config.tsx b/apps/explorer/src/app/routes/router-config.tsx index a4f2a0560..66ea07656 100644 --- a/apps/explorer/src/app/routes/router-config.tsx +++ b/apps/explorer/src/app/routes/router-config.tsx @@ -12,7 +12,7 @@ import Genesis from './genesis'; import { Block } from './blocks/id'; import { Blocks } from './blocks/home'; import { Tx } from './txs/id'; -import { Txs as TxHome } from './txs/home'; +import { TxsHome } from './txs/home'; import { PendingTxs } from './pending'; import flags from '../config/flags'; import { t } from '@vegaprotocol/react-helpers'; @@ -129,7 +129,7 @@ const routerConfig = [ }, { index: true, - element: , + element: , }, ], }, diff --git a/apps/explorer/src/app/routes/txs/home/index.tsx b/apps/explorer/src/app/routes/txs/home/index.tsx index 049e1603e..5316a02ea 100644 --- a/apps/explorer/src/app/routes/txs/home/index.tsx +++ b/apps/explorer/src/app/routes/txs/home/index.tsx @@ -1,32 +1,122 @@ -import type { TendermintBlockchainResponse } from '../../blocks/tendermint-blockchain-response'; import { DATA_SOURCES } from '../../../config'; +import { useCallback, useState, useMemo } from 'react'; +import { t, useFetch } from '@vegaprotocol/react-helpers'; import { RouteTitle } from '../../../components/route-title'; import { BlocksRefetch } from '../../../components/blocks'; -import { RenderFetched } from '../../../components/render-fetched'; -import { BlockTxsData } from '../../../components/txs'; import { JumpToBlock } from '../../../components/jump-to-block'; -import { t, useFetch } from '@vegaprotocol/react-helpers'; +import { TxsInfiniteList } from '../../../components/txs'; +import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; +import type { ChainExplorerTxResponse } from '../../types/chain-explorer-response'; +import type { TendermintBlockchainResponse } from '../../blocks/tendermint-blockchain-response'; -const Txs = () => { - const { - state: { data, error, loading }, - refetch, - } = useFetch( - `${DATA_SOURCES.tendermintUrl}/blockchain` +interface TxsProps { + latestBlockHeight: string; +} + +interface TxsStateProps { + txsData: ChainExplorerTxResponse[]; + hasMoreTxs: boolean; + nextPage: number; +} + +const Txs = ({ latestBlockHeight }: TxsProps) => { + const [{ txsData, hasMoreTxs, nextPage }, setTxsState] = + useState({ + txsData: [], + hasMoreTxs: true, + nextPage: 1, + }); + + const reusedBodyParams = useMemo( + () => ({ + node_url: DATA_SOURCES.tendermintUrl, + transaction_height: parseInt(latestBlockHeight), + page_size: 30, + }), + [latestBlockHeight] ); + const { + state: { error, loading }, + refetch, + } = useFetch( + DATA_SOURCES.chainExplorerUrl, + { + method: 'POST', + body: JSON.stringify(reusedBodyParams), + }, + false + ); + + const loadTxs = useCallback(async () => { + const data = await refetch( + undefined, + JSON.stringify({ + ...reusedBodyParams, + page_number: nextPage, + }) + ); + + if (data) { + setTxsState((prev) => ({ + ...prev, + nextPage: prev.nextPage + 1, + hasMoreTxs: true, + txsData: [...prev.txsData, ...(data as ChainExplorerTxResponse[])], + })); + } + }, [nextPage, refetch, reusedBodyParams]); + return (
{t('Transactions')} - - <> - - - - + + refetch( + undefined, + JSON.stringify({ + ...reusedBodyParams, + page_number: 1, + }) + ) + } + /> +
); }; -export { Txs }; +const Wrapper = () => { + const { + state: { data, error, loading }, + } = useFetch( + `${DATA_SOURCES.tendermintUrl}/blockchain` + ); + + return ( + ( + + )} + /> + ); +}; + +export { Wrapper as TxsHome }; diff --git a/libs/react-helpers/src/hooks/use-fetch.ts b/libs/react-helpers/src/hooks/use-fetch.ts index 7665ca8be..d3df3e631 100644 --- a/libs/react-helpers/src/hooks/use-fetch.ts +++ b/libs/react-helpers/src/hooks/use-fetch.ts @@ -25,7 +25,8 @@ export const useFetch = ( ): { state: State; refetch: ( - params?: Record | undefined + params?: Record | undefined, + body?: BodyInit ) => Promise; } => { // Used to prevent state update if the component is unmounted @@ -51,7 +52,10 @@ export const useFetch = ( const [state, dispatch] = useReducer(fetchReducer, initialState); const fetchCallback = useCallback( - async (params?: Record) => { + async ( + params?: Record, + body?: BodyInit + ) => { if (!url) return; const fetchData = async () => { @@ -67,7 +71,10 @@ export const useFetch = ( } } - const response = await fetch(assembledUrl.toString(), options); + const response = await fetch(assembledUrl.toString(), { + ...options, + body: body ? body : options?.body, + }); if (!response.ok) { throw new Error(response.statusText); } diff --git a/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx b/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx index 16ef721e4..8690b36ae 100644 --- a/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx +++ b/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx @@ -4,29 +4,41 @@ import { t } from '@vegaprotocol/react-helpers'; interface AsyncRendererProps { loading: boolean; + loadingMessage?: string; error: Error | undefined | null; + errorMessage?: string; data: T | undefined; + noDataMessage?: string; children?: ReactNode | null; render?: (data: T) => ReactNode; } export function AsyncRenderer({ loading, + loadingMessage, error, + errorMessage, data, + noDataMessage, children, render, }: AsyncRendererProps) { if (error) { - return {t(`Something went wrong: ${error.message}`)}; + return ( + + {errorMessage + ? errorMessage + : t(`Something went wrong: ${error.message}`)} + + ); } if (loading) { - return {t('Loading...')}; + return {loadingMessage ? loadingMessage : t('Loading...')}; } if (!data) { - return {t('No data')}; + return {noDataMessage ? noDataMessage : t('No data')}; } // eslint-disable-next-line react/jsx-no-useless-fragment return <>{render ? render(data) : children};