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 <dexter.edwards93@gmail.com>

* 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 <dexter.edwards93@gmail.com>
This commit is contained in:
Sam Keen 2022-06-29 11:20:22 +01:00 committed by GitHub
parent 8e276d9933
commit 2094a29d35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 546 additions and 32 deletions

View File

@ -1,3 +1,5 @@
@ignore
# tendermint times out getting txs on testnet atm
Feature: Transactions Page Feature: Transactions Page
Scenario: Navigate to transactions page Scenario: Navigate to transactions page
@ -5,14 +7,12 @@ Feature: Transactions Page
When I navigate to the transactions page When I navigate to the transactions page
Then transactions page is correctly displayed Then transactions page is correctly displayed
@ignore
Scenario: Navigate to transaction details page Scenario: Navigate to transaction details page
Given I am on the homepage Given I am on the homepage
When I navigate to the transactions page When I navigate to the transactions page
And I click on the top transaction And I click on the top transaction
Then transaction details are displayed Then transaction details are displayed
@ignore
Scenario: Navigate to transactions page using mobile Scenario: Navigate to transactions page using mobile
Given I am on mobile and open the toggle menu Given I am on mobile and open the toggle menu
When I navigate to the transactions page When I navigate to the transactions page

View File

@ -15,11 +15,9 @@ export default class TransactionsPage extends BasePage {
txType = 'tx-type'; txType = 'tx-type';
validateTransactionsPagedisplayed() { validateTransactionsPagedisplayed() {
cy.getByTestId(this.transactionsList).should('have.length.above', 1); // eslint-disable-next-line cypress/no-unnecessary-waiting
cy.getByTestId(this.blockHeight).first().should('not.be.empty'); cy.wait(5000); // Wait for transactions to load if there are any
cy.getByTestId(this.numberOfTransactions).first().should('not.be.empty'); cy.getByTestId(this.transactionRow).should('have.length.above', 1);
cy.getByTestId(this.validatorLink).first().should('not.be.empty');
cy.getByTestId(this.blockTime).first().should('not.be.empty');
} }
validateRefreshBtn() { validateRefreshBtn() {

View File

@ -1,3 +1,5 @@
export { TxList } from './tx-list'; export { TxList } from './tx-list';
export { BlockTxsData } from './block-txs-data'; export { BlockTxsData } from './block-txs-data';
export { TxOrderType } from './tx-order-type'; export { TxOrderType } from './tx-order-type';
export { TxsInfiniteList } from './txs-infinite-list';
export { TxsInfiniteListItem } from './txs-infinite-list-item';

View File

@ -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(
<TxsInfiniteListItem
// @ts-ignore testing deliberate failure
Type={undefined}
Command={'test'}
Sig={'test'}
PubKey={'test'}
Nonce={1}
TxHash={'test'}
/>
);
expect(screen.getByText('Missing vital data')).toBeInTheDocument();
});
it('should display "missing vital data" if "Command" data missing', () => {
render(
<TxsInfiniteListItem
Type={'test'}
// @ts-ignore testing deliberate failure
Command={undefined}
Sig={'test'}
PubKey={'test'}
Nonce={1}
TxHash={'test'}
/>
);
expect(screen.getByText('Missing vital data')).toBeInTheDocument();
});
it('should display "missing vital data" if "Pubkey" data missing', () => {
render(
<TxsInfiniteListItem
Type={'test'}
Command={'test'}
Sig={'test'}
// @ts-ignore testing deliberate failure
PubKey={undefined}
Nonce={1}
TxHash={'test'}
/>
);
expect(screen.getByText('Missing vital data')).toBeInTheDocument();
});
it('should display "missing vital data" if "TxHash" data missing', () => {
render(
<TxsInfiniteListItem
Type={'test'}
Command={'test'}
Sig={'test'}
PubKey={'test'}
Nonce={1}
// @ts-ignore testing deliberate failure
TxHash={undefined}
/>
);
expect(screen.getByText('Missing vital data')).toBeInTheDocument();
});
it('renders data correctly', () => {
const testCommandData = JSON.stringify({
test: 'test of command data',
});
render(
<MemoryRouter>
<TxsInfiniteListItem
Type={'testType'}
Command={testCommandData}
Sig={'testSig'}
PubKey={'testPubKey'}
Nonce={1}
TxHash={'testTxHash'}
/>
</MemoryRouter>
);
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();
});
});

View File

@ -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 <div>Missing vital data</div>;
}
return (
<div
data-testid="transaction-row"
className="grid grid-cols-[repeat(2,_1fr)_240px] gap-12 w-full border-t border-black-60 dark:border-white-25 py-8 txs-infinite-list-item"
>
<div className="whitespace-nowrap overflow-scroll" data-testid="tx-hash">
<TruncatedLink
to={`/${Routes.TX}/${TxHash}`}
text={TxHash}
startChars={TRUNCATE_LENGTH}
endChars={TRUNCATE_LENGTH}
/>
</div>
<div className="whitespace-nowrap overflow-scroll" data-testid="pub-key">
<TruncatedLink
to={`/${Routes.PARTIES}/${PubKey}`}
text={PubKey}
startChars={TRUNCATE_LENGTH}
endChars={TRUNCATE_LENGTH}
/>
</div>
<div
className="flex justify-between whitespace-nowrap overflow-scroll"
data-testid="type"
>
<TxOrderType orderType={Type} />
<button
title="More details"
onClick={() => setOpen(true)}
data-testid="command-details"
>
<Icon name="search-template" />
<Dialog
open={open}
onChange={(isOpen) => setOpen(isOpen)}
intent={Intent.None}
>
<SyntaxHighlighter data={JSON.parse(Command)} />
</Dialog>
</button>
</div>
</div>
);
};

View File

@ -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(
<TxsInfiniteList
txs={undefined}
areTxsLoading={false}
hasMoreTxs={false}
loadMoreTxs={() => null}
error={undefined}
/>
);
expect(screen.getByText('No items')).toBeInTheDocument();
});
it('error is displayed at item level', () => {
const txs = generateTxs(1);
render(
<TxsInfiniteList
txs={txs}
areTxsLoading={false}
hasMoreTxs={false}
loadMoreTxs={() => 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(
<MemoryRouter>
<TxsInfiniteList
txs={txs}
areTxsLoading={false}
hasMoreTxs={false}
loadMoreTxs={() => null}
error={undefined}
/>
</MemoryRouter>
);
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(
<MemoryRouter>
<TxsInfiniteList
txs={txs}
areTxsLoading={false}
hasMoreTxs={true}
loadMoreTxs={callback}
error={undefined}
/>
</MemoryRouter>
);
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(
<MemoryRouter>
<TxsInfiniteList
txs={txs}
areTxsLoading={false}
hasMoreTxs={false}
loadMoreTxs={callback}
error={undefined}
/>
</MemoryRouter>
);
expect(callback.mock.calls.length).toEqual(0);
});
it('loads more items is called when scrolled', () => {
const txs = generateTxs(20);
const callback = jest.fn();
render(
<MemoryRouter>
<TxsInfiniteList
txs={txs}
areTxsLoading={false}
hasMoreTxs={true}
loadMoreTxs={callback}
error={undefined}
/>
</MemoryRouter>
);
act(() => {
fireEvent.scroll(screen.getByTestId('infinite-scroll-wrapper'), {
target: { scrollY: 600 },
});
});
expect(callback.mock.calls.length).toEqual(1);
});
});

View File

@ -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 = (
<TxsInfiniteListItem
Type={Type}
Command={Command}
Sig={Sig}
PubKey={PubKey}
Nonce={Nonce}
TxHash={TxHash}
/>
);
}
return <div style={style}>{content}</div>;
};
export const TxsInfiniteList = ({
hasMoreTxs,
areTxsLoading,
txs,
loadMoreTxs,
error,
className,
}: TxsInfiniteListProps) => {
if (!txs) {
return <div>No items</div>;
}
// 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 (
<div className={className} data-testid="transactions-list">
<div className="grid grid-cols-[repeat(2,_1fr)_240px] gap-12 w-full mb-8">
<div className="text-h5 font-bold">Txn hash</div>
<div className="text-h5 font-bold">Party</div>
<div className="text-h5 font-bold pl-2">Type</div>
</div>
<div data-testid="infinite-scroll-wrapper">
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<List
className="List"
height={595}
itemCount={itemCount}
itemSize={41}
onItemsRendered={onItemsRendered}
ref={ref}
width={'100%'}
>
{({ index, style }) => (
<Item
index={txs[index]}
style={style}
isLoading={!isItemLoaded(index)}
error={error}
/>
)}
</List>
)}
</InfiniteLoader>
</div>
</div>
);
};

View File

@ -12,7 +12,7 @@ import Genesis from './genesis';
import { Block } from './blocks/id'; import { Block } from './blocks/id';
import { Blocks } from './blocks/home'; import { Blocks } from './blocks/home';
import { Tx } from './txs/id'; import { Tx } from './txs/id';
import { Txs as TxHome } from './txs/home'; import { TxsHome } from './txs/home';
import { PendingTxs } from './pending'; import { PendingTxs } from './pending';
import flags from '../config/flags'; import flags from '../config/flags';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
@ -129,7 +129,7 @@ const routerConfig = [
}, },
{ {
index: true, index: true,
element: <TxHome />, element: <TxsHome />,
}, },
], ],
}, },

View File

@ -1,32 +1,122 @@
import type { TendermintBlockchainResponse } from '../../blocks/tendermint-blockchain-response';
import { DATA_SOURCES } from '../../../config'; 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 { RouteTitle } from '../../../components/route-title';
import { BlocksRefetch } from '../../../components/blocks'; import { BlocksRefetch } from '../../../components/blocks';
import { RenderFetched } from '../../../components/render-fetched';
import { BlockTxsData } from '../../../components/txs';
import { JumpToBlock } from '../../../components/jump-to-block'; 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 = () => { interface TxsProps {
const { latestBlockHeight: string;
state: { data, error, loading }, }
refetch,
} = useFetch<TendermintBlockchainResponse>( interface TxsStateProps {
`${DATA_SOURCES.tendermintUrl}/blockchain` txsData: ChainExplorerTxResponse[];
hasMoreTxs: boolean;
nextPage: number;
}
const Txs = ({ latestBlockHeight }: TxsProps) => {
const [{ txsData, hasMoreTxs, nextPage }, setTxsState] =
useState<TxsStateProps>({
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 ( return (
<section> <section>
<RouteTitle>{t('Transactions')}</RouteTitle> <RouteTitle>{t('Transactions')}</RouteTitle>
<RenderFetched error={error} loading={loading}> <BlocksRefetch
<> refetch={() =>
<BlocksRefetch refetch={refetch} /> refetch(
<BlockTxsData data={data} /> undefined,
</> JSON.stringify({
</RenderFetched> ...reusedBodyParams,
page_number: 1,
})
)
}
/>
<TxsInfiniteList
hasMoreTxs={hasMoreTxs}
areTxsLoading={loading}
txs={txsData}
loadMoreTxs={loadTxs}
error={error}
className="mb-28"
/>
<JumpToBlock /> <JumpToBlock />
</section> </section>
); );
}; };
export { Txs }; const Wrapper = () => {
const {
state: { data, error, loading },
} = useFetch<TendermintBlockchainResponse>(
`${DATA_SOURCES.tendermintUrl}/blockchain`
);
return (
<AsyncRenderer
loading={!!loading}
loadingMessage={t('Getting latest block height...')}
error={error}
data={data}
noDataMessage={t('Could not get latest block height')}
render={(data) => (
<Txs
latestBlockHeight={
data?.result?.block_metas?.[0]?.header?.height || ''
}
/>
)}
/>
);
};
export { Wrapper as TxsHome };

View File

@ -25,7 +25,8 @@ export const useFetch = <T>(
): { ): {
state: State<T>; state: State<T>;
refetch: ( refetch: (
params?: Record<string, string | number | null | undefined> | undefined params?: Record<string, string | number | null | undefined> | undefined,
body?: BodyInit
) => Promise<T | undefined>; ) => Promise<T | undefined>;
} => { } => {
// Used to prevent state update if the component is unmounted // Used to prevent state update if the component is unmounted
@ -51,7 +52,10 @@ export const useFetch = <T>(
const [state, dispatch] = useReducer(fetchReducer, initialState); const [state, dispatch] = useReducer(fetchReducer, initialState);
const fetchCallback = useCallback( const fetchCallback = useCallback(
async (params?: Record<string, string | null | undefined | number>) => { async (
params?: Record<string, string | null | undefined | number>,
body?: BodyInit
) => {
if (!url) return; if (!url) return;
const fetchData = async () => { const fetchData = async () => {
@ -67,7 +71,10 @@ export const useFetch = <T>(
} }
} }
const response = await fetch(assembledUrl.toString(), options); const response = await fetch(assembledUrl.toString(), {
...options,
body: body ? body : options?.body,
});
if (!response.ok) { if (!response.ok) {
throw new Error(response.statusText); throw new Error(response.statusText);
} }

View File

@ -4,29 +4,41 @@ import { t } from '@vegaprotocol/react-helpers';
interface AsyncRendererProps<T> { interface AsyncRendererProps<T> {
loading: boolean; loading: boolean;
loadingMessage?: string;
error: Error | undefined | null; error: Error | undefined | null;
errorMessage?: string;
data: T | undefined; data: T | undefined;
noDataMessage?: string;
children?: ReactNode | null; children?: ReactNode | null;
render?: (data: T) => ReactNode; render?: (data: T) => ReactNode;
} }
export function AsyncRenderer<T = object>({ export function AsyncRenderer<T = object>({
loading, loading,
loadingMessage,
error, error,
errorMessage,
data, data,
noDataMessage,
children, children,
render, render,
}: AsyncRendererProps<T>) { }: AsyncRendererProps<T>) {
if (error) { if (error) {
return <Splash>{t(`Something went wrong: ${error.message}`)}</Splash>; return (
<Splash>
{errorMessage
? errorMessage
: t(`Something went wrong: ${error.message}`)}
</Splash>
);
} }
if (loading) { if (loading) {
return <Splash>{t('Loading...')}</Splash>; return <Splash>{loadingMessage ? loadingMessage : t('Loading...')}</Splash>;
} }
if (!data) { if (!data) {
return <Splash>{t('No data')}</Splash>; return <Splash>{noDataMessage ? noDataMessage : t('No data')}</Splash>;
} }
// eslint-disable-next-line react/jsx-no-useless-fragment // eslint-disable-next-line react/jsx-no-useless-fragment
return <>{render ? render(data) : children}</>; return <>{render ? render(data) : children}</>;