diff --git a/apps/explorer/src/app/components/info-block/info-block.tsx b/apps/explorer/src/app/components/info-block/info-block.tsx index 8a347bc81..b233b7960 100644 --- a/apps/explorer/src/app/components/info-block/info-block.tsx +++ b/apps/explorer/src/app/components/info-block/info-block.tsx @@ -1,5 +1,4 @@ import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit'; -import React from 'react'; export interface InfoBlockProps { title: string; diff --git a/apps/explorer/src/app/components/links/party-link/party-link.spec.tsx b/apps/explorer/src/app/components/links/party-link/party-link.spec.tsx index 31688be56..2bd700e69 100644 --- a/apps/explorer/src/app/components/links/party-link/party-link.spec.tsx +++ b/apps/explorer/src/app/components/links/party-link/party-link.spec.tsx @@ -1,33 +1,98 @@ import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import PartyLink from './party-link'; +import { MockedProvider } from '@apollo/client/testing'; +import { ExplorerNodeNamesDocument } from '../../../routes/validators/__generated__/NodeNames'; +import { act } from 'react-dom/test-utils'; +const zeroes = + '0000000000000000000000000000000000000000000000000000000000000000'; +const mocks = [ + { + request: { + query: ExplorerNodeNamesDocument, + }, + result: { + data: { + nodesConnection: { + edges: [ + { + node: { + id: '1', + name: 'Validator Node', + pubkey: + '13464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6e', + tmPubkey: 'tmPubkey1', + ethereumAddress: '0x123456789', + }, + }, + { + node: { + id: '2', + name: 'Node 2', + pubkey: 'pubkey2', + tmPubkey: 'tmPubkey2', + ethereumAddress: '0xabcdef123', + }, + }, + ], + }, + }, + }, + }, +]; describe('PartyLink', () => { it('renders Network for 000.000 party', () => { - const zeroes = - '0000000000000000000000000000000000000000000000000000000000000000'; - const screen = render(); + const screen = render( + + + + ); expect(screen.getByText('Network')).toBeInTheDocument(); }); it('renders Network for network party', () => { - const screen = render(); + const screen = render( + + + + ); expect(screen.getByText('Network')).toBeInTheDocument(); }); it('renders ID with no link for invalid party', () => { - const screen = render(); + const screen = render( + + + + ); expect(screen.getByTestId('invalid-party')).toBeInTheDocument(); }); + it('if the key is a validator, render their name instead', async () => { + const screen = render( + + + + + + ); + + // Wait for hook to update with mock data + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + await expect(screen.getByText('Validator Node')).toBeInTheDocument(); + }); + it('links a valid party to the party page', () => { const aValidParty = '13464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6e'; const screen = render( - - - + + + + + ); const el = screen.getByText(aValidParty); diff --git a/apps/explorer/src/app/components/links/party-link/party-link.tsx b/apps/explorer/src/app/components/links/party-link/party-link.tsx index 3524cdadf..2709be93f 100644 --- a/apps/explorer/src/app/components/links/party-link/party-link.tsx +++ b/apps/explorer/src/app/components/links/party-link/party-link.tsx @@ -1,22 +1,44 @@ import { Routes } from '../../../routes/route-names'; import { Link } from 'react-router-dom'; -import type { ComponentProps } from 'react'; +import { useMemo, type ComponentProps } from 'react'; import Hash from '../hash'; import { t } from '@vegaprotocol/i18n'; import { isValidPartyId } from '../../../routes/parties/id/components/party-id-error'; -import { truncateMiddle } from '@vegaprotocol/ui-toolkit'; +import { Icon, truncateMiddle } from '@vegaprotocol/ui-toolkit'; +import { useExplorerNodeNamesQuery } from '../../../routes/validators/__generated__/NodeNames'; +import type { ExplorerNodeNamesQuery } from '../../../routes/validators/__generated__/NodeNames'; export const SPECIAL_CASE_NETWORK_ID = '0000000000000000000000000000000000000000000000000000000000000000'; export const SPECIAL_CASE_NETWORK = 'network'; +export function getNameForParty(id: string, data?: ExplorerNodeNamesQuery) { + if (!data || data?.nodesConnection?.edges?.length === 0) { + return id; + } + + const validator = data.nodesConnection.edges?.find((e) => { + return e?.node.pubkey === id; + }); + + if (validator) { + return validator.node.name; + } + + return id; +} + export type PartyLinkProps = Partial> & { id: string; truncate?: boolean; }; const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => { + const { data } = useExplorerNodeNamesQuery(); + const name = useMemo(() => getNameForParty(id, data), [data, id]); + const useName = name !== id; + // Some transactions will involve the 'network' party, which is alias for '000...000' // The party page does not handle this nicely, so in this case we render the word 'Network' if (id === SPECIAL_CASE_NETWORK || id === SPECIAL_CASE_NETWORK_ID) { @@ -38,13 +60,20 @@ const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => { } return ( - - - + + {useName && } + + {useName ? ( + name + ) : ( + + )} + + ); }; diff --git a/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx b/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx index 6e6eee29c..55558b6a3 100644 --- a/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@vegaprotocol/ui-toolkit'; +import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit'; // https://github.com/vegaprotocol/vega/blob/develop/core/blockchain/response.go export const ErrorCodes = new Map([ @@ -17,6 +17,8 @@ interface ChainResponseCodeProps { code: number; hideLabel?: boolean; error?: string; + hideIfOk?: boolean; + small?: boolean; } /** @@ -28,14 +30,21 @@ export const ChainResponseCode = ({ code, hideLabel = false, error, + hideIfOk = false, + small = false, }: ChainResponseCodeProps) => { + if (hideIfOk && code === 0) { + return null; + } + const isSuccess = successCodes.has(code); + const size = small ? 3 : 4; const successColour = - code === 71 ? 'fill-vega-orange' : 'fill-vega-green-600'; + code === 71 ? '!fill-vega-orange' : '!fill-vega-green-600'; const icon = isSuccess ? ( - + ) : ( - + ); const label = ErrorCodes.get(code) || 'Unknown response code'; @@ -44,18 +53,28 @@ export const ChainResponseCode = ({ error && error.length > 100 ? error.replace(/,/g, ',\r\n') : error; return ( -
- - {icon} - - {hideLabel ? null : {label}} - {!hideLabel && !!displayError ? ( - — {displayError} - ) : null} -
+ + Response code: {code} - {label} + + } + > +
+ + {icon} + + {hideLabel ? null : {label}} + {!hideLabel && !!displayError ? ( + + — {displayError} + + ) : null} +
+
); }; diff --git a/apps/explorer/src/app/components/txs/tx-filter-label.tsx b/apps/explorer/src/app/components/txs/tx-filter-label.tsx index dd759d0a8..2aeb07e6c 100644 --- a/apps/explorer/src/app/components/txs/tx-filter-label.tsx +++ b/apps/explorer/src/app/components/txs/tx-filter-label.tsx @@ -10,13 +10,17 @@ export interface FilterLabelProps { */ export function FilterLabel({ filters }: FilterLabelProps) { if (!filters || filters.size !== 1) { - return {t('Filter')}; + return ( + + {t('Filter')} + + ); } return ( -
+
{t('Filters')}:  - + {Array.from(filters)[0]}
diff --git a/apps/explorer/src/app/components/txs/tx-filter.spec.tsx b/apps/explorer/src/app/components/txs/tx-filter.spec.tsx new file mode 100644 index 000000000..720927d25 --- /dev/null +++ b/apps/explorer/src/app/components/txs/tx-filter.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { TxsFilter } from './tx-filter'; +import type { FilterOption } from './tx-filter'; + +describe('TxsFilter', () => { + it('renders holding text when nothing is selected', () => { + const filters: Set = new Set([]); + const setFilters = jest.fn(); + render(); + expect(screen.getByTestId('filter-empty')).toBeInTheDocument(); + expect(screen.getByText('Filter')).toBeInTheDocument(); + }); + + it('renders the submit order filter as selected', () => { + const filters: Set = new Set(['Submit Order']); + const setFilters = jest.fn(); + render(); + expect(screen.getByTestId('filter-selected')).toBeInTheDocument(); + expect(screen.getByText('Submit Order')).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/txs/tx-filter.tsx b/apps/explorer/src/app/components/txs/tx-filter.tsx index b2b4ead30..022c5efdb 100644 --- a/apps/explorer/src/app/components/txs/tx-filter.tsx +++ b/apps/explorer/src/app/components/txs/tx-filter.tsx @@ -91,7 +91,7 @@ export interface TxFilterProps { * types. It allows a user to select one transaction type to view. Later * it will support multiple selection, but until the API supports that it is * one or all. - * @param filters null or Set of tranaction types + * @param filters null or Set of transaction types * @param setFilters A function to update the filters prop * @returns */ @@ -100,15 +100,15 @@ export const TxsFilter = ({ filters, setFilters }: TxFilterProps) => { - } > - {filters.size > 1 ? null : ( + {filters.size > 0 ? null : ( <> setFilters(new Set(AllFilterOptions))} diff --git a/apps/explorer/src/app/components/txs/tx-list-navigation.spec.tsx b/apps/explorer/src/app/components/txs/tx-list-navigation.spec.tsx new file mode 100644 index 000000000..d08d3da52 --- /dev/null +++ b/apps/explorer/src/app/components/txs/tx-list-navigation.spec.tsx @@ -0,0 +1,113 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { TxsListNavigation } from './tx-list-navigation'; + +const NOOP = () => { + return; +}; +describe('TxsListNavigation', () => { + it('renders transaction list navigation', () => { + render( + + + + ); + + expect(screen.getByText('Newer')).toBeInTheDocument(); + expect(screen.getByText('Older')).toBeInTheDocument(); + }); + + it('calls previousPage when "Newer" button is clicked', () => { + const previousPageMock = jest.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByText('Newer')); + + expect(previousPageMock).toHaveBeenCalledTimes(1); + }); + + it('calls nextPage when "Older" button is clicked', () => { + const nextPageMock = jest.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByText('Older')); + + expect(nextPageMock).toHaveBeenCalledTimes(1); + }); + + it('disables "Older" button if hasMoreTxs is false', () => { + render( + + + + ); + + expect(screen.getByText('Older')).toBeDisabled(); + }); + + it('disables "Newer" button if hasPreviousPage is false', () => { + render( + + + + ); + + expect(screen.getByText('Newer')).toBeDisabled(); + }); + + it('disables both buttons when more and previous are false', () => { + render( + + + + ); + + expect(screen.getByText('Newer')).toBeDisabled(); + expect(screen.getByText('Older')).toBeDisabled(); + }); +}); diff --git a/apps/explorer/src/app/components/txs/tx-list-navigation.tsx b/apps/explorer/src/app/components/txs/tx-list-navigation.tsx new file mode 100644 index 000000000..66851999f --- /dev/null +++ b/apps/explorer/src/app/components/txs/tx-list-navigation.tsx @@ -0,0 +1,63 @@ +import { t } from '@vegaprotocol/i18n'; +import { BlocksRefetch } from '../blocks'; + +import { Button } from '@vegaprotocol/ui-toolkit'; + +export interface TxListNavigationProps { + refreshTxs: () => void; + nextPage: () => void; + previousPage: () => void; + loading?: boolean; + hasPreviousPage: boolean; + hasMoreTxs: boolean; + children: React.ReactNode; +} +/** + * Displays a list of transactions with filters and controls to navigate through the list. + * + * @returns {JSX.Element} Transaction List and controls + */ +export const TxsListNavigation = ({ + refreshTxs, + nextPage, + previousPage, + hasMoreTxs, + hasPreviousPage, + children, + loading = false, +}: TxListNavigationProps) => { + return ( + <> + {children} + + +
+ + +
+
+ {loading ? ( + {t('Loading...')} + ) : null} +
+
+ + ); +}; diff --git a/apps/explorer/src/app/components/txs/tx-order-type.tsx b/apps/explorer/src/app/components/txs/tx-order-type.tsx index 96f96b19d..69b125d25 100644 --- a/apps/explorer/src/app/components/txs/tx-order-type.tsx +++ b/apps/explorer/src/app/components/txs/tx-order-type.tsx @@ -24,6 +24,7 @@ const displayString: StringMap = { LiquidityProvisionSubmission: 'LP order', 'Liquidity Provision Order': 'LP order', LiquidityProvisionCancellation: 'LP cancel', + 'Cancel LiquidityProvision Order': 'LP cancel', LiquidityProvisionAmendment: 'LP update', 'Amend LiquidityProvision Order': 'Amend LP', ProposalSubmission: 'Governance Proposal', @@ -36,9 +37,12 @@ const displayString: StringMap = { UndelegateSubmission: 'Undelegation', KeyRotateSubmission: 'Key Rotation', StateVariableProposal: 'State Variable', + 'State Variable Proposal': 'State Variable', Transfer: 'Transfer', CancelTransfer: 'Cancel Transfer', + 'Cancel Transfer Funds': 'Cancel Transfer', ValidatorHeartbeat: 'Heartbeat', + 'Validator Heartbeat': 'Heartbeat', 'Batch Market Instructions': 'Batch', }; @@ -172,7 +176,7 @@ export const TxOrderType = ({ orderType, command }: TxOrderTypeProps) => { return (
{type}
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 index b83eb4fc9..cca9bf7ad 100644 --- 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 @@ -1,3 +1,4 @@ +import { MockedProvider } from '@apollo/client/testing'; import { TxsInfiniteListItem } from './txs-infinite-list-item'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; @@ -83,21 +84,22 @@ describe('Txs infinite list item', () => { it('renders data correctly', () => { render( - - - + + + + + ); expect(screen.getByTestId('tx-hash')).toHaveTextContent('testTxHash'); expect(screen.getByTestId('pub-key')).toHaveTextContent('testPubKey'); expect(screen.getByTestId('tx-type')).toHaveTextContent('testType'); expect(screen.getByTestId('tx-block')).toHaveTextContent('1'); - expect(screen.getByTestId('tx-success')).toHaveTextContent('Success'); }); }); 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 index 8f761b01c..9b91c5dde 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { TruncatedLink } from '../truncate/truncated-link'; import { Routes } from '../../routes/route-names'; import { TxOrderType } from './tx-order-type'; @@ -6,8 +5,25 @@ import type { BlockExplorerTransactionResult } from '../../routes/types/block-ex import { toHex } from '../search/detect-search'; import { ChainResponseCode } from './details/chain-response-code/chain-reponse.code'; import isNumber from 'lodash/isNumber'; +import { PartyLink } from '../links'; +import { useScreenDimensions } from '@vegaprotocol/react-helpers'; +import type { Screen } from '@vegaprotocol/react-helpers'; +import { useMemo } from 'react'; -const TRUNCATE_LENGTH = 10; +const DEFAULT_TRUNCATE_LENGTH = 7; + +export function getIdTruncateLength(screen: Screen): number { + if (['xxxl', 'xxl'].includes(screen)) { + return 64; + } else if (['xl', 'lg', 'md'].includes(screen)) { + return 32; + } + return DEFAULT_TRUNCATE_LENGTH; +} + +export function shouldTruncateParty(screen: Screen): boolean { + return !['xxxl', 'xxl', 'xl'].includes(screen); +} export const TxsInfiniteListItem = ({ hash, @@ -17,6 +33,12 @@ export const TxsInfiniteListItem = ({ block, command, }: Partial) => { + const { screenSize } = useScreenDimensions(); + const idTruncateLength = useMemo( + () => getIdTruncateLength(screenSize), + [screenSize] + ); + if ( !hash || !submitter || @@ -29,68 +51,40 @@ export const TxsInfiniteListItem = ({ } return ( -
-
- - ID:  - - -
-
- - By:  - - -
-
- -
-
- - Block:  - - -
-
- - Success  - {isNumber(code) ? ( - + ) : ( code )} -
-
+ + + + + + + + + + + + ); }; 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 index c02b1a413..80d12fffd 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx @@ -1,8 +1,9 @@ import { TxsInfiniteList } from './txs-infinite-list'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; import { Side } from '@vegaprotocol/types'; +import { MockedProvider } from '@apollo/client/testing'; const generateTxs = (number: number): BlockExplorerTransactionResult[] => { return Array.from(Array(number)).map((_) => ({ @@ -40,7 +41,7 @@ describe('Txs infinite list', () => { it('should display a "no items" message when no items provided', () => { render( null} @@ -48,23 +49,7 @@ describe('Txs infinite list', () => { /> ); expect(screen.getByTestId('emptylist')).toBeInTheDocument(); - expect( - screen.getByText('This chain has 0 transactions') - ).toBeInTheDocument(); - }); - - it('error is displayed at item level', () => { - const txs = generateTxs(1); - render( - null} - error={Error('test error!')} - /> - ); - expect(screen.getByText('Cannot fetch transaction')).toBeInTheDocument(); + expect(screen.getByText('No transactions found')).toBeInTheDocument(); }); it('item renders data of n length into list of n length', () => { @@ -73,85 +58,22 @@ describe('Txs infinite list', () => { const txs = generateTxs(7); render( - null} - error={undefined} - /> + + null} + error={undefined} + /> + ); expect( screen - .getByTestId('infinite-scroll-wrapper') - .querySelectorAll('.txs-infinite-list-item') + .getByTestId('transactions-list') + .querySelectorAll('.transaction-row') ).toHaveLength(7); }); - - 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(14); - const callback = jest.fn(); - - render( - - - - ); - - act(() => { - fireEvent.scroll(screen.getByTestId('infinite-scroll-wrapper'), { - target: { scrollY: 2000 }, - }); - }); - - 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 index e7f1000be..23fc54d12 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx @@ -1,8 +1,4 @@ -import React, { useEffect, useRef } from 'react'; -import { FixedSizeList as List } from 'react-window'; -import InfiniteLoader from 'react-window-infinite-loader'; import { t } from '@vegaprotocol/i18n'; -import { useScreenDimensions } from '@vegaprotocol/react-helpers'; import { TxsInfiniteListItem } from './txs-infinite-list-item'; import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; import EmptyList from '../empty-list/empty-list'; @@ -11,82 +7,46 @@ import { Loader } from '@vegaprotocol/ui-toolkit'; interface TxsInfiniteListProps { hasMoreTxs: boolean; areTxsLoading: boolean | undefined; - txs: BlockExplorerTransactionResult[] | undefined; + txs: BlockExplorerTransactionResult[]; loadMoreTxs: () => void; error: Error | undefined; className?: string; + hasFilters?: boolean; } interface ItemProps { - index: BlockExplorerTransactionResult; - style: React.CSSProperties; - isLoading: boolean; - error: Error | undefined; + tx: BlockExplorerTransactionResult; } -// eslint-disable-next-line @typescript-eslint/no-empty-function -const NOOP = () => {}; - -const Item = ({ index, style, isLoading, error }: ItemProps) => { - let content; - if (error) { - content = t(`Cannot fetch transaction`); - } else if (isLoading) { - content = ; - } else { - const { - hash, - submitter, - type, - command, - block, - code, - index: blockIndex, - } = index; - content = ( - - ); - } - - return
{content}
; +const Item = ({ tx }: ItemProps) => { + const { hash, submitter, type, command, block, code, index: blockIndex } = tx; + return ( + + ); }; export const TxsInfiniteList = ({ - hasMoreTxs, areTxsLoading, txs, - loadMoreTxs, - error, className, + hasFilters = false, }: TxsInfiniteListProps) => { - const { screenSize } = useScreenDimensions(); - const isStacked = ['xs', 'sm'].includes(screenSize); - const infiniteLoaderRef = useRef(null); - const hasMountedRef = useRef(false); - - useEffect(() => { - if (hasMountedRef.current) { - if (infiniteLoaderRef.current) { - infiniteLoaderRef.current.resetloadMoreItemsCache(true); - } - } - hasMountedRef.current = true; - }, [loadMoreTxs]); - - if (!txs) { + if (!txs || txs.length === 0) { if (!areTxsLoading) { return ( ); } else { @@ -94,57 +54,26 @@ export const TxsInfiniteList = ({ } } - // 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 ( -
-
-
- {t('Transaction')}   - ID -
-
{t('Submitted By')}
-
{t('Type')}
-
{t('Block')}
-
{t('Success')}
-
-
- - {({ onItemsRendered, ref }) => ( - - {({ index, style }) => ( - - )} - - )} - -
+
+ + + + + + + + + + + {txs.map((t) => ( + + ))} + +
+ {t('Txn')}   + ID + {t('Type')}{t('From')}{t('Block')}
); }; 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 e672447a4..e571237bf 100644 --- a/apps/explorer/src/app/components/txs/txs-per-block.tsx +++ b/apps/explorer/src/app/components/txs/txs-per-block.tsx @@ -1,23 +1,17 @@ -import { Routes } from '../../routes/route-names'; -import { TruncatedLink } from '../truncate/truncated-link'; -import { TxOrderType } from './tx-order-type'; -import { Table, TableRow, TableCell } from '../table'; +import { Table, TableRow } from '../table'; import { t } from '@vegaprotocol/i18n'; import { useFetch } from '@vegaprotocol/react-helpers'; import type { BlockExplorerTransactions } from '../../routes/types/block-explorer-response'; -import isNumber from 'lodash/isNumber'; -import { ChainResponseCode } from './details/chain-response-code/chain-reponse.code'; import { getTxsDataUrl } from '../../hooks/use-txs-data'; import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit'; import EmptyList from '../empty-list/empty-list'; +import { TxsInfiniteListItem } from './txs-infinite-list-item'; interface TxsPerBlockProps { blockHeight: string; txCount: number; } -const truncateLength = 5; - export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => { const filters = `filters[block.height]=${blockHeight}`; const url = getTxsDataUrl({ limit: txCount.toString(), filters }); @@ -33,53 +27,23 @@ export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => { {t('Transaction')} - {t('From')} {t('Type')} - {t('Status')} + {t('From')} + {t('Block')} {data.transactions.map( - ({ hash, submitter, type, command, code }) => { + ({ hash, submitter, type, command, code, block }) => { return ( - - - - - - - - - - - - {isNumber(code) ? ( - - ) : ( - code - )} - - + ); } )} diff --git a/apps/explorer/src/app/components/vote-icon/vote-icon.tsx b/apps/explorer/src/app/components/vote-icon/vote-icon.tsx index 2bfcb95d2..c48b414e8 100644 --- a/apps/explorer/src/app/components/vote-icon/vote-icon.tsx +++ b/apps/explorer/src/app/components/vote-icon/vote-icon.tsx @@ -56,10 +56,14 @@ export function VoteIcon({ return (
- - + + {label}
diff --git a/apps/explorer/src/app/hooks/use-txs-data.ts b/apps/explorer/src/app/hooks/use-txs-data.ts index 43811dd8a..ba7c17942 100644 --- a/apps/explorer/src/app/hooks/use-txs-data.ts +++ b/apps/explorer/src/app/hooks/use-txs-data.ts @@ -10,16 +10,18 @@ import isNumber from 'lodash/isNumber'; export interface TxsStateProps { txsData: BlockExplorerTransactionResult[]; hasMoreTxs: boolean; - lastCursor: string; + cursor: string; + previousCursors: string[]; + hasPreviousPage: boolean; } export interface IUseTxsData { - limit?: number; + limit: number; filters?: string; } interface IGetTxsDataUrl { - limit?: string; + limit: string; filters?: string; } @@ -40,63 +42,89 @@ export const getTxsDataUrl = ({ limit, filters }: IGetTxsDataUrl) => { }; export const useTxsData = ({ limit, filters }: IUseTxsData) => { - const [{ txsData, hasMoreTxs, lastCursor }, setTxsState] = - useState({ - txsData: [], - hasMoreTxs: true, - lastCursor: '', - }); + const [ + { txsData, hasMoreTxs, cursor, previousCursors, hasPreviousPage }, + setTxsState, + ] = useState({ + txsData: [], + hasMoreTxs: false, + previousCursors: [], + cursor: '', + hasPreviousPage: false, + }); - const url = getTxsDataUrl({ limit: limit?.toString(), filters }); + const url = getTxsDataUrl({ limit: limit.toString(), filters }); const { state: { data, error, loading }, refetch, - } = useFetch(url, {}, false); + } = useFetch(url, {}, true); useEffect(() => { - if (data && isNumber(data?.transactions?.length)) { - setTxsState((prev) => ({ - txsData: [...prev.txsData, ...data.transactions], - hasMoreTxs: data.transactions.length > 0, - lastCursor: - data.transactions[data.transactions.length - 1]?.cursor || '', - })); + if (!loading && data && isNumber(data.transactions.length)) { + setTxsState((prev) => { + return { + ...prev, + txsData: data.transactions, + hasMoreTxs: data.transactions.length >= limit, + cursor: data?.transactions.at(-1)?.cursor || '', + }; + }); } - }, [setTxsState, data]); + }, [loading, setTxsState, data, limit]); - useEffect(() => { - setTxsState((prev) => ({ - txsData: [], - hasMoreTxs: true, - lastCursor: '', - })); - }, [filters]); + const nextPage = useCallback(() => { + const c = data?.transactions.at(0)?.cursor; + const newPreviousCursors = c ? [...previousCursors, c] : previousCursors; - const loadTxs = useCallback(() => { - return refetch({ - limit: limit, - before: lastCursor, - }); - }, [lastCursor, limit, refetch]); - - const refreshTxs = useCallback(async () => { setTxsState((prev) => ({ ...prev, - lastCursor: '', - hasMoreTxs: true, - txsData: [], + hasPreviousPage: true, + previousCursors: newPreviousCursors, })); - }, [setTxsState]); + + return refetch({ + limit, + before: cursor, + }); + }, [data, previousCursors, cursor, limit, refetch]); + + const previousPage = useCallback(() => { + const previousCursor = [...previousCursors].pop(); + const newPreviousCursors = previousCursors.slice(0, -1); + setTxsState((prev) => ({ + ...prev, + hasPreviousPage: newPreviousCursors.length > 0, + previousCursors: newPreviousCursors, + })); + return refetch({ + limit, + before: previousCursor, + }); + }, [previousCursors, limit, refetch]); + + const refreshTxs = useCallback(async () => { + setTxsState(() => ({ + txsData: [], + cursor: '', + previousCursors: [], + hasMoreTxs: false, + hasPreviousPage: false, + })); + + refetch({ limit }); + }, [setTxsState, limit, refetch, filters]); // eslint-disable-line react-hooks/exhaustive-deps return { - data, + txsData, loading, error, - txsData, hasMoreTxs, - lastCursor, + hasPreviousPage, + previousCursors, + cursor, refreshTxs, - loadTxs, + nextPage, + previousPage, }; }; diff --git a/apps/explorer/src/app/routes/layout.tsx b/apps/explorer/src/app/routes/layout.tsx index c11cca676..139795b2d 100644 --- a/apps/explorer/src/app/routes/layout.tsx +++ b/apps/explorer/src/app/routes/layout.tsx @@ -21,6 +21,7 @@ import { import { Footer } from '../components/footer/footer'; import { Header } from '../components/header'; import { Routes } from './route-names'; +import { useExplorerNodeNamesLazyQuery } from './validators/__generated__/NodeNames'; const DialogsContainer = () => { const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore(); @@ -39,6 +40,7 @@ export const Layout = () => { const isHome = Boolean(useMatch(Routes.HOME)); const { ANNOUNCEMENTS_CONFIG_URL } = useEnvironment(); const fixedWidthClasses = 'w-full max-w-[1500px] mx-auto'; + useExplorerNodeNamesLazyQuery(); return ( <> @@ -49,7 +51,7 @@ export const Layout = () => { 'grid grid-rows-[auto_1fr_auto] grid-cols-1', 'border-vega-light-200 dark:border-vega-dark-200', 'antialiased text-black dark:text-white', - 'overflow-hidden relative' + 'relative' )} >
diff --git a/apps/explorer/src/app/routes/oracles/components/oracle-signers.spec.tsx b/apps/explorer/src/app/routes/oracles/components/oracle-signers.spec.tsx index 6bd3b7f3a..7fc693214 100644 --- a/apps/explorer/src/app/routes/oracles/components/oracle-signers.spec.tsx +++ b/apps/explorer/src/app/routes/oracles/components/oracle-signers.spec.tsx @@ -2,12 +2,15 @@ import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { SourceType } from './oracle'; import { OracleSigners } from './oracle-signers'; +import { MockedProvider } from '@apollo/client/testing'; function renderComponent(sourceType: SourceType) { return ( - - - + + + + + ); } diff --git a/apps/explorer/src/app/routes/parties/id/index.tsx b/apps/explorer/src/app/routes/parties/id/index.tsx index 9f2b1e638..80cd3d501 100644 --- a/apps/explorer/src/app/routes/parties/id/index.tsx +++ b/apps/explorer/src/app/routes/parties/id/index.tsx @@ -1,6 +1,6 @@ import { t } from '@vegaprotocol/i18n'; import { useScreenDimensions } from '@vegaprotocol/react-helpers'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { SubHeading } from '../../../components/sub-heading'; import { toNonHex } from '../../../components/search/detect-search'; @@ -14,8 +14,11 @@ import { PartyBlockStake } from './components/party-block-stake'; import { PartyBlockAccounts } from './components/party-block-accounts'; import { isValidPartyId } from './components/party-id-error'; import { useDataProvider } from '@vegaprotocol/data-provider'; +import { TxsListNavigation } from '../../../components/txs/tx-list-navigation'; +import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter'; const Party = () => { + const [filters, setFilters] = useState(new Set(AllFilterOptions)); const { party } = useParams<{ party: string }>(); useDocumentTitle(['Public keys', party || '-']); @@ -24,10 +27,24 @@ const Party = () => { const partyId = toNonHex(party ? party : ''); const { isMobile } = useScreenDimensions(); const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]); - const filters = `filters[tx.submitter]=${partyId}`; - const { hasMoreTxs, loadTxs, error, txsData, loading } = useTxsData({ - limit: 10, - filters, + const baseFilters = `filters[tx.submitter]=${partyId}`; + const f = + filters && filters.size === 1 + ? `${baseFilters}&filters[cmd.type]=${Array.from(filters)[0]}` + : baseFilters; + + const { + hasMoreTxs, + nextPage, + previousPage, + error, + refreshTxs, + loading, + txsData, + hasPreviousPage, + } = useTxsData({ + limit: 25, + filters: f, }); const variables = useMemo(() => ({ partyId }), [partyId]); @@ -81,14 +98,24 @@ const Party = () => {
{t('Transactions')} + + + {!error && txsData ? ( ) : ( diff --git a/apps/explorer/src/app/routes/txs/home/index.tsx b/apps/explorer/src/app/routes/txs/home/index.tsx index d24283de4..3816cbb00 100644 --- a/apps/explorer/src/app/routes/txs/home/index.tsx +++ b/apps/explorer/src/app/routes/txs/home/index.tsx @@ -1,14 +1,14 @@ import { t } from '@vegaprotocol/i18n'; import { RouteTitle } from '../../../components/route-title'; -import { BlocksRefetch } from '../../../components/blocks'; import { TxsInfiniteList } from '../../../components/txs'; import { useTxsData } from '../../../hooks/use-txs-data'; import { useDocumentTitle } from '../../../hooks/use-document-title'; import { useState } from 'react'; import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter'; +import { TxsListNavigation } from '../../../components/txs/tx-list-navigation'; -const BE_TXS_PER_REQUEST = 15; +const BE_TXS_PER_REQUEST = 25; export const TxsList = () => { useDocumentTitle(['Transactions']); @@ -21,6 +21,11 @@ export const TxsList = () => { ); }; +/** + * Displays a list of transactions with filters and controls to navigate through the list. + * + * @returns {JSX.Element} Transaction List and controls + */ export const TxsListFiltered = () => { const [filters, setFilters] = useState(new Set(AllFilterOptions)); @@ -29,26 +34,40 @@ export const TxsListFiltered = () => { ? `filters[cmd.type]=${Array.from(filters)[0]}` : ''; - const { hasMoreTxs, loadTxs, error, txsData, refreshTxs, loading } = - useTxsData({ - limit: BE_TXS_PER_REQUEST, - filters: f, - }); + const { + hasMoreTxs, + nextPage, + previousPage, + error, + refreshTxs, + loading, + txsData, + hasPreviousPage, + } = useTxsData({ + limit: BE_TXS_PER_REQUEST, + filters: f, + }); return ( <> - - + - - + 0} hasMoreTxs={hasMoreTxs} areTxsLoading={loading} txs={txsData} - loadMoreTxs={loadTxs} + loadMoreTxs={nextPage} error={error} - className="mb-28" + className="mb-28 w-full min-w-[400px]" /> ); diff --git a/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx b/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx index c7f18b1d8..7b58423bb 100644 --- a/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx +++ b/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx @@ -1,10 +1,11 @@ -import { BrowserRouter as Router } from 'react-router-dom'; import { render, screen } from '@testing-library/react'; import { TxDetails } from './tx-details'; import type { BlockExplorerTransactionResult, ValidatorHeartbeat, } from '../../../routes/types/block-explorer-response'; +import { MemoryRouter } from 'react-router-dom'; +import { MockedProvider } from '@apollo/client/testing'; // Note: Long enough that there is a truncated output and a full output const pubKey = @@ -27,9 +28,11 @@ const txData: BlockExplorerTransactionResult = { }; const renderComponent = (txData: BlockExplorerTransactionResult) => ( - - - + + + + + ); describe('Transaction details', () => { diff --git a/apps/explorer/src/app/routes/validators/NodeNames.graphql b/apps/explorer/src/app/routes/validators/NodeNames.graphql new file mode 100644 index 000000000..984aaff57 --- /dev/null +++ b/apps/explorer/src/app/routes/validators/NodeNames.graphql @@ -0,0 +1,13 @@ +query ExplorerNodeNames { + nodesConnection { + edges { + node { + id + name + pubkey + tmPubkey + ethereumAddress + } + } + } +} diff --git a/apps/explorer/src/app/routes/validators/__generated__/NodeNames.ts b/apps/explorer/src/app/routes/validators/__generated__/NodeNames.ts new file mode 100644 index 000000000..0f47cb165 --- /dev/null +++ b/apps/explorer/src/app/routes/validators/__generated__/NodeNames.ts @@ -0,0 +1,53 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ExplorerNodeNamesQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type ExplorerNodeNamesQuery = { __typename?: 'Query', nodesConnection: { __typename?: 'NodesConnection', edges?: Array<{ __typename?: 'NodeEdge', node: { __typename?: 'Node', id: string, name: string, pubkey: string, tmPubkey: string, ethereumAddress: string } } | null> | null } }; + + +export const ExplorerNodeNamesDocument = gql` + query ExplorerNodeNames { + nodesConnection { + edges { + node { + id + name + pubkey + tmPubkey + ethereumAddress + } + } + } +} + `; + +/** + * __useExplorerNodeNamesQuery__ + * + * To run a query within a React component, call `useExplorerNodeNamesQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerNodeNamesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useExplorerNodeNamesQuery({ + * variables: { + * }, + * }); + */ +export function useExplorerNodeNamesQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerNodeNamesDocument, options); + } +export function useExplorerNodeNamesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerNodeNamesDocument, options); + } +export type ExplorerNodeNamesQueryHookResult = ReturnType; +export type ExplorerNodeNamesLazyQueryHookResult = ReturnType; +export type ExplorerNodeNamesQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/libs/ui-toolkit/src/components/viewing-as-user/index.tsx b/libs/ui-toolkit/src/components/viewing-as-user/index.tsx index deb69c510..da7e042b5 100644 --- a/libs/ui-toolkit/src/components/viewing-as-user/index.tsx +++ b/libs/ui-toolkit/src/components/viewing-as-user/index.tsx @@ -1,12 +1,12 @@ import { Button } from '../button'; import { t } from '@vegaprotocol/i18n'; -export function truncateMiddle(address: string) { +export function truncateMiddle(address: string, start = 6, end = 4) { if (address.length < 11) return address; return ( - address.slice(0, 6) + + address.slice(0, start) + '\u2026' + - address.slice(address.length - 4, address.length) + address.slice(address.length - end, address.length) ); } diff --git a/libs/utils/src/lib/format/strings.spec.ts b/libs/utils/src/lib/format/strings.spec.ts index d864b1310..9d38117db 100644 --- a/libs/utils/src/lib/format/strings.spec.ts +++ b/libs/utils/src/lib/format/strings.spec.ts @@ -16,7 +16,8 @@ describe('truncateByChars', () => { }, { i: '12345678901234567890', s: 0, e: 10, o: `${ELLIPSIS}1234567890` }, { i: '123', s: 0, e: 4, o: '123' }, - ])('should truncate given string by specific chars', ({ i, s, e, o }) => { + { i: '12345678901234567890', s: 3, e: 0, o: `123${ELLIPSIS}` }, + ])('should truncate given string by specific chars: %s', ({ i, s, e, o }) => { expect(truncateByChars(i, s, e)).toStrictEqual(o); }); }); @@ -28,7 +29,7 @@ describe('shorten', () => { { i: '12345678901234567890', l: 10, o: `123456789${ELLIPSIS}` }, { i: '12345678901234567890', l: 20, o: `1234567890123456789${ELLIPSIS}` }, { i: '12345678901234567890', l: 30, o: `12345678901234567890` }, - ])('should shorten given string by specific limit', ({ i, l, o }) => { + ])('should shorten given string by specific limit: %s', ({ i, l, o }) => { const output = shorten(i, l); expect(output).toStrictEqual(o); }); @@ -51,7 +52,7 @@ describe('titlefy', () => { words: ['VEGAUSD', '123.22'], o: 'VEGAUSD - 123.22 - Vega', }, - ])('should convert to title-like string', ({ words, o }) => { + ])('should convert to title-like string: %s', ({ words, o }) => { expect(titlefy(words)).toEqual(o); }); }); diff --git a/libs/utils/src/lib/format/strings.ts b/libs/utils/src/lib/format/strings.ts index 4f455e9ee..a10dbfb4b 100644 --- a/libs/utils/src/lib/format/strings.ts +++ b/libs/utils/src/lib/format/strings.ts @@ -6,7 +6,10 @@ export function truncateByChars(input: string, start = 6, end = 6) { if (input.length <= start + end + 1) { return input; } - return input.slice(0, start) + ELLIPSIS + input.slice(-end); + + const s = input.slice(0, start); + const e = end !== 0 ? input.slice(-end) : ''; + return `${s}${ELLIPSIS}${e}`; } export function shorten(input: string, limit?: number) {