feat(explorer): remove infinite tx loader (#4217)

This commit is contained in:
Edd 2023-07-04 12:51:28 +01:00 committed by GitHub
parent 8c8fe6878a
commit 74f2cfa4a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 729 additions and 445 deletions

View File

@ -1,5 +1,4 @@
import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit'; import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit';
import React from 'react';
export interface InfoBlockProps { export interface InfoBlockProps {
title: string; title: string;

View File

@ -1,33 +1,98 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import PartyLink from './party-link'; 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', () => { describe('PartyLink', () => {
it('renders Network for 000.000 party', () => { it('renders Network for 000.000 party', () => {
const zeroes = const screen = render(
'0000000000000000000000000000000000000000000000000000000000000000'; <MockedProvider>
const screen = render(<PartyLink id={zeroes} />); <PartyLink id={zeroes} />
</MockedProvider>
);
expect(screen.getByText('Network')).toBeInTheDocument(); expect(screen.getByText('Network')).toBeInTheDocument();
}); });
it('renders Network for network party', () => { it('renders Network for network party', () => {
const screen = render(<PartyLink id="network" />); const screen = render(
<MockedProvider>
<PartyLink id="network" />
</MockedProvider>
);
expect(screen.getByText('Network')).toBeInTheDocument(); expect(screen.getByText('Network')).toBeInTheDocument();
}); });
it('renders ID with no link for invalid party', () => { it('renders ID with no link for invalid party', () => {
const screen = render(<PartyLink id="this-party-is-not-valid" />); const screen = render(
<MockedProvider>
<PartyLink id="this-party-is-not-valid" />
</MockedProvider>
);
expect(screen.getByTestId('invalid-party')).toBeInTheDocument(); expect(screen.getByTestId('invalid-party')).toBeInTheDocument();
}); });
it('if the key is a validator, render their name instead', async () => {
const screen = render(
<MockedProvider mocks={mocks}>
<MemoryRouter>
<PartyLink id="13464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6e" />
</MemoryRouter>
</MockedProvider>
);
// 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', () => { it('links a valid party to the party page', () => {
const aValidParty = const aValidParty =
'13464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6e'; '13464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6e';
const screen = render( const screen = render(
<MockedProvider>
<MemoryRouter> <MemoryRouter>
<PartyLink id={aValidParty} /> <PartyLink id={aValidParty} />
</MemoryRouter> </MemoryRouter>
</MockedProvider>
); );
const el = screen.getByText(aValidParty); const el = screen.getByText(aValidParty);

View File

@ -1,22 +1,44 @@
import { Routes } from '../../../routes/route-names'; import { Routes } from '../../../routes/route-names';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ComponentProps } from 'react'; import { useMemo, type ComponentProps } from 'react';
import Hash from '../hash'; import Hash from '../hash';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { isValidPartyId } from '../../../routes/parties/id/components/party-id-error'; 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 = export const SPECIAL_CASE_NETWORK_ID =
'0000000000000000000000000000000000000000000000000000000000000000'; '0000000000000000000000000000000000000000000000000000000000000000';
export const SPECIAL_CASE_NETWORK = 'network'; 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<ComponentProps<typeof Link>> & { export type PartyLinkProps = Partial<ComponentProps<typeof Link>> & {
id: string; id: string;
truncate?: boolean; truncate?: boolean;
}; };
const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => { 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' // 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' // 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) { if (id === SPECIAL_CASE_NETWORK || id === SPECIAL_CASE_NETWORK_ID) {
@ -38,13 +60,20 @@ const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => {
} }
return ( return (
<span className="whitespace-nowrap">
{useName && <Icon size={4} name="cube" className="mr-2" />}
<Link <Link
className="underline font-mono" className="underline font-mono"
{...props} {...props}
to={`/${Routes.PARTIES}/${id}`} to={`/${Routes.PARTIES}/${id}`}
> >
<Hash text={truncate ? truncateMiddle(id) : id} /> {useName ? (
name
) : (
<Hash text={truncate ? truncateMiddle(id, 4, 4) : id} />
)}
</Link> </Link>
</span>
); );
}; };

View File

@ -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 // https://github.com/vegaprotocol/vega/blob/develop/core/blockchain/response.go
export const ErrorCodes = new Map([ export const ErrorCodes = new Map([
@ -17,6 +17,8 @@ interface ChainResponseCodeProps {
code: number; code: number;
hideLabel?: boolean; hideLabel?: boolean;
error?: string; error?: string;
hideIfOk?: boolean;
small?: boolean;
} }
/** /**
@ -28,14 +30,21 @@ export const ChainResponseCode = ({
code, code,
hideLabel = false, hideLabel = false,
error, error,
hideIfOk = false,
small = false,
}: ChainResponseCodeProps) => { }: ChainResponseCodeProps) => {
if (hideIfOk && code === 0) {
return null;
}
const isSuccess = successCodes.has(code); const isSuccess = successCodes.has(code);
const size = small ? 3 : 4;
const successColour = const successColour =
code === 71 ? 'fill-vega-orange' : 'fill-vega-green-600'; code === 71 ? '!fill-vega-orange' : '!fill-vega-green-600';
const icon = isSuccess ? ( const icon = isSuccess ? (
<Icon name="tick-circle" className={successColour} /> <Icon size={size} name="tick-circle" className={`${successColour}`} />
) : ( ) : (
<Icon name="cross" className="fill-vega-pink-600" /> <Icon size={size} name="cross" className="!fill-vega-pink-500" />
); );
const label = ErrorCodes.get(code) || 'Unknown response code'; const label = ErrorCodes.get(code) || 'Unknown response code';
@ -44,7 +53,14 @@ export const ChainResponseCode = ({
error && error.length > 100 ? error.replace(/,/g, ',\r\n') : error; error && error.length > 100 ? error.replace(/,/g, ',\r\n') : error;
return ( return (
<div title={`Response code: ${code} - ${label}`} className=" inline-block"> <Tooltip
description={
<span>
Response code: {code} - {label}
</span>
}
>
<div className="mt-[-1px] inline-block">
<span <span
className="mr-2" className="mr-2"
aria-label={isSuccess ? 'Success' : 'Warning'} aria-label={isSuccess ? 'Success' : 'Warning'}
@ -54,8 +70,11 @@ export const ChainResponseCode = ({
</span> </span>
{hideLabel ? null : <span>{label}</span>} {hideLabel ? null : <span>{label}</span>}
{!hideLabel && !!displayError ? ( {!hideLabel && !!displayError ? (
<span className="ml-1 whitespace-pre">&mdash;&nbsp;{displayError}</span> <span className="ml-1 whitespace-pre">
&mdash;&nbsp;{displayError}
</span>
) : null} ) : null}
</div> </div>
</Tooltip>
); );
}; };

View File

@ -10,13 +10,17 @@ export interface FilterLabelProps {
*/ */
export function FilterLabel({ filters }: FilterLabelProps) { export function FilterLabel({ filters }: FilterLabelProps) {
if (!filters || filters.size !== 1) { if (!filters || filters.size !== 1) {
return <span className="uppercase">{t('Filter')}</span>; return (
<span data-testid="filter-empty" className="uppercase">
{t('Filter')}
</span>
);
} }
return ( return (
<div> <div data-testid="filter-selected">
<span className="uppercase">{t('Filters')}:</span>&nbsp; <span className="uppercase">{t('Filters')}:</span>&nbsp;
<code className="bg-vega-light-150 px-2 rounded-md capitalize"> <code className="bg-vega-light-150 dark:bg-vega-light-300 px-2 rounded-md capitalize dark:text-black">
{Array.from(filters)[0]} {Array.from(filters)[0]}
</code> </code>
</div> </div>

View File

@ -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<FilterOption> = new Set([]);
const setFilters = jest.fn();
render(<TxsFilter filters={filters} setFilters={setFilters} />);
expect(screen.getByTestId('filter-empty')).toBeInTheDocument();
expect(screen.getByText('Filter')).toBeInTheDocument();
});
it('renders the submit order filter as selected', () => {
const filters: Set<FilterOption> = new Set(['Submit Order']);
const setFilters = jest.fn();
render(<TxsFilter filters={filters} setFilters={setFilters} />);
expect(screen.getByTestId('filter-selected')).toBeInTheDocument();
expect(screen.getByText('Submit Order')).toBeInTheDocument();
});
});

View File

@ -91,7 +91,7 @@ export interface TxFilterProps {
* types. It allows a user to select one transaction type to view. Later * 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 * it will support multiple selection, but until the API supports that it is
* one or all. * 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 * @param setFilters A function to update the filters prop
* @returns * @returns
*/ */
@ -100,15 +100,15 @@ export const TxsFilter = ({ filters, setFilters }: TxFilterProps) => {
<DropdownMenu <DropdownMenu
modal={false} modal={false}
trigger={ trigger={
<DropdownMenuTrigger className="ml-2"> <DropdownMenuTrigger className="ml-0">
<Button size="xs"> <Button size="xs" data-testid="filter-trigger">
<FilterLabel filters={filters} /> <FilterLabel filters={filters} />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
} }
> >
<DropdownMenuContent> <DropdownMenuContent>
{filters.size > 1 ? null : ( {filters.size > 0 ? null : (
<> <>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
onCheckedChange={() => setFilters(new Set(AllFilterOptions))} onCheckedChange={() => setFilters(new Set(AllFilterOptions))}

View File

@ -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(
<TxsListNavigation
refreshTxs={NOOP}
nextPage={NOOP}
previousPage={NOOP}
hasMoreTxs={true}
hasPreviousPage={true}
>
<span></span>
</TxsListNavigation>
);
expect(screen.getByText('Newer')).toBeInTheDocument();
expect(screen.getByText('Older')).toBeInTheDocument();
});
it('calls previousPage when "Newer" button is clicked', () => {
const previousPageMock = jest.fn();
render(
<TxsListNavigation
refreshTxs={NOOP}
nextPage={NOOP}
previousPage={previousPageMock}
hasMoreTxs={true}
hasPreviousPage={true}
>
<span></span>
</TxsListNavigation>
);
fireEvent.click(screen.getByText('Newer'));
expect(previousPageMock).toHaveBeenCalledTimes(1);
});
it('calls nextPage when "Older" button is clicked', () => {
const nextPageMock = jest.fn();
render(
<TxsListNavigation
refreshTxs={NOOP}
nextPage={nextPageMock}
previousPage={NOOP}
hasMoreTxs={true}
hasPreviousPage={true}
>
<span></span>
</TxsListNavigation>
);
fireEvent.click(screen.getByText('Older'));
expect(nextPageMock).toHaveBeenCalledTimes(1);
});
it('disables "Older" button if hasMoreTxs is false', () => {
render(
<TxsListNavigation
refreshTxs={NOOP}
nextPage={NOOP}
previousPage={NOOP}
hasMoreTxs={false}
hasPreviousPage={false}
>
<span></span>
</TxsListNavigation>
);
expect(screen.getByText('Older')).toBeDisabled();
});
it('disables "Newer" button if hasPreviousPage is false', () => {
render(
<TxsListNavigation
refreshTxs={NOOP}
nextPage={NOOP}
previousPage={NOOP}
hasMoreTxs={true}
hasPreviousPage={false}
>
<span></span>
</TxsListNavigation>
);
expect(screen.getByText('Newer')).toBeDisabled();
});
it('disables both buttons when more and previous are false', () => {
render(
<TxsListNavigation
refreshTxs={NOOP}
nextPage={NOOP}
previousPage={NOOP}
hasMoreTxs={false}
hasPreviousPage={false}
>
<span></span>
</TxsListNavigation>
);
expect(screen.getByText('Newer')).toBeDisabled();
expect(screen.getByText('Older')).toBeDisabled();
});
});

View File

@ -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 (
<>
<menu className="mb-2 w-full ">{children}</menu>
<menu className="mb-2 w-full">
<BlocksRefetch refetch={refreshTxs} />
<div className="float-right">
<Button
className="mr-2"
size="xs"
disabled={!hasPreviousPage || loading}
onClick={() => {
previousPage();
}}
>
{t('Newer')}
</Button>
<Button
size="xs"
disabled={!hasMoreTxs || loading}
onClick={() => {
nextPage();
}}
>
{t('Older')}
</Button>
</div>
<div className="float-right mr-2">
{loading ? (
<span className="text-vega-light-300">{t('Loading...')}</span>
) : null}
</div>
</menu>
</>
);
};

View File

@ -24,6 +24,7 @@ const displayString: StringMap = {
LiquidityProvisionSubmission: 'LP order', LiquidityProvisionSubmission: 'LP order',
'Liquidity Provision Order': 'LP order', 'Liquidity Provision Order': 'LP order',
LiquidityProvisionCancellation: 'LP cancel', LiquidityProvisionCancellation: 'LP cancel',
'Cancel LiquidityProvision Order': 'LP cancel',
LiquidityProvisionAmendment: 'LP update', LiquidityProvisionAmendment: 'LP update',
'Amend LiquidityProvision Order': 'Amend LP', 'Amend LiquidityProvision Order': 'Amend LP',
ProposalSubmission: 'Governance Proposal', ProposalSubmission: 'Governance Proposal',
@ -36,9 +37,12 @@ const displayString: StringMap = {
UndelegateSubmission: 'Undelegation', UndelegateSubmission: 'Undelegation',
KeyRotateSubmission: 'Key Rotation', KeyRotateSubmission: 'Key Rotation',
StateVariableProposal: 'State Variable', StateVariableProposal: 'State Variable',
'State Variable Proposal': 'State Variable',
Transfer: 'Transfer', Transfer: 'Transfer',
CancelTransfer: 'Cancel Transfer', CancelTransfer: 'Cancel Transfer',
'Cancel Transfer Funds': 'Cancel Transfer',
ValidatorHeartbeat: 'Heartbeat', ValidatorHeartbeat: 'Heartbeat',
'Validator Heartbeat': 'Heartbeat',
'Batch Market Instructions': 'Batch', 'Batch Market Instructions': 'Batch',
}; };
@ -172,7 +176,7 @@ export const TxOrderType = ({ orderType, command }: TxOrderTypeProps) => {
return ( return (
<div <div
data-testid="tx-type" data-testid="tx-type"
className={`text-sm rounded-md leading-none px-2 py-2 inline-block ${colours}`} className={`text-sm rounded-md leading-tight px-2 inline-block whitespace-nowrap ${colours}`}
> >
{type} {type}
</div> </div>

View File

@ -1,3 +1,4 @@
import { MockedProvider } from '@apollo/client/testing';
import { TxsInfiniteListItem } from './txs-infinite-list-item'; import { TxsInfiniteListItem } from './txs-infinite-list-item';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
@ -83,6 +84,7 @@ describe('Txs infinite list item', () => {
it('renders data correctly', () => { it('renders data correctly', () => {
render( render(
<MockedProvider>
<MemoryRouter> <MemoryRouter>
<TxsInfiniteListItem <TxsInfiniteListItem
type="testType" type="testType"
@ -93,11 +95,11 @@ describe('Txs infinite list item', () => {
command={{}} command={{}}
/> />
</MemoryRouter> </MemoryRouter>
</MockedProvider>
); );
expect(screen.getByTestId('tx-hash')).toHaveTextContent('testTxHash'); expect(screen.getByTestId('tx-hash')).toHaveTextContent('testTxHash');
expect(screen.getByTestId('pub-key')).toHaveTextContent('testPubKey'); expect(screen.getByTestId('pub-key')).toHaveTextContent('testPubKey');
expect(screen.getByTestId('tx-type')).toHaveTextContent('testType'); expect(screen.getByTestId('tx-type')).toHaveTextContent('testType');
expect(screen.getByTestId('tx-block')).toHaveTextContent('1'); expect(screen.getByTestId('tx-block')).toHaveTextContent('1');
expect(screen.getByTestId('tx-success')).toHaveTextContent('Success');
}); });
}); });

View File

@ -1,4 +1,3 @@
import React from 'react';
import { TruncatedLink } from '../truncate/truncated-link'; import { TruncatedLink } from '../truncate/truncated-link';
import { Routes } from '../../routes/route-names'; import { Routes } from '../../routes/route-names';
import { TxOrderType } from './tx-order-type'; 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 { toHex } from '../search/detect-search';
import { ChainResponseCode } from './details/chain-response-code/chain-reponse.code'; import { ChainResponseCode } from './details/chain-response-code/chain-reponse.code';
import isNumber from 'lodash/isNumber'; 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 = ({ export const TxsInfiniteListItem = ({
hash, hash,
@ -17,6 +33,12 @@ export const TxsInfiniteListItem = ({
block, block,
command, command,
}: Partial<BlockExplorerTransactionResult>) => { }: Partial<BlockExplorerTransactionResult>) => {
const { screenSize } = useScreenDimensions();
const idTruncateLength = useMemo(
() => getIdTruncateLength(screenSize),
[screenSize]
);
if ( if (
!hash || !hash ||
!submitter || !submitter ||
@ -29,68 +51,40 @@ export const TxsInfiniteListItem = ({
} }
return ( return (
<div <tr
data-testid="transaction-row" data-testid="transaction-row"
className="flex items-center h-full border-t border-neutral-600 dark:border-neutral-800 txs-infinite-list-item grid grid-cols-10" className="transaction-row text-left items-center h-full border-t border-neutral-600 dark:border-neutral-800 txs-infinite-list-item py-[2px]"
> >
<div <td
className="text-sm col-span-10 md:col-span-3 leading-none" className="text-sm leading-none whitespace-nowrap font-mono"
data-testid="tx-hash" data-testid="tx-hash"
> >
<span className="md:hidden uppercase text-vega-dark-300">
ID:&nbsp;
</span>
<TruncatedLink
to={`/${Routes.TX}/${toHex(hash)}`}
text={hash}
startChars={TRUNCATE_LENGTH}
endChars={TRUNCATE_LENGTH}
/>
</div>
<div
className="text-sm col-span-10 md:col-span-3 leading-none"
data-testid="pub-key"
>
<span className="md:hidden uppercase text-vega-dark-300">
By:&nbsp;
</span>
<TruncatedLink
to={`/${Routes.PARTIES}/${submitter}`}
text={submitter}
startChars={TRUNCATE_LENGTH}
endChars={TRUNCATE_LENGTH}
/>
</div>
<div className="text-sm col-span-5 md:col-span-2 leading-none flex items-center">
<TxOrderType orderType={type} command={command} />
</div>
<div
className="text-sm col-span-3 md:col-span-1 leading-none flex items-center"
data-testid="tx-block"
>
<span className="md:hidden uppercase text-vega-dark-300">
Block:&nbsp;
</span>
<TruncatedLink
to={`/${Routes.BLOCKS}/${block}`}
text={block}
startChars={TRUNCATE_LENGTH}
endChars={TRUNCATE_LENGTH}
/>
</div>
<div
className="text-sm col-span-2 md:col-span-1 leading-none flex items-center"
data-testid="tx-success"
>
<span className="md:hidden uppercase text-vega-dark-300">
Success&nbsp;
</span>
{isNumber(code) ? ( {isNumber(code) ? (
<ChainResponseCode code={code} hideLabel={true} /> <ChainResponseCode code={code} hideLabel={true} hideIfOk={true} />
) : ( ) : (
code code
)} )}
</div> <TruncatedLink
</div> to={`/${Routes.TX}/${toHex(hash)}`}
text={hash}
startChars={idTruncateLength}
endChars={0}
/>
</td>
<td className="text-sm leading-none">
<TxOrderType orderType={type} command={command} />
</td>
<td className="text-sm leading-none" data-testid="pub-key">
<PartyLink truncate={shouldTruncateParty(screenSize)} id={submitter} />
</td>
<td className="text-sm items-center font-mono" data-testid="tx-block">
<TruncatedLink
to={`/${Routes.BLOCKS}/${block}`}
text={block}
startChars={5}
endChars={5}
/>
</td>
</tr>
); );
}; };

View File

@ -1,8 +1,9 @@
import { TxsInfiniteList } from './txs-infinite-list'; 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 { MemoryRouter } from 'react-router-dom';
import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response';
import { Side } from '@vegaprotocol/types'; import { Side } from '@vegaprotocol/types';
import { MockedProvider } from '@apollo/client/testing';
const generateTxs = (number: number): BlockExplorerTransactionResult[] => { const generateTxs = (number: number): BlockExplorerTransactionResult[] => {
return Array.from(Array(number)).map((_) => ({ 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', () => { it('should display a "no items" message when no items provided', () => {
render( render(
<TxsInfiniteList <TxsInfiniteList
txs={undefined} txs={undefined as unknown as BlockExplorerTransactionResult[]}
areTxsLoading={false} areTxsLoading={false}
hasMoreTxs={false} hasMoreTxs={false}
loadMoreTxs={() => null} loadMoreTxs={() => null}
@ -48,23 +49,7 @@ describe('Txs infinite list', () => {
/> />
); );
expect(screen.getByTestId('emptylist')).toBeInTheDocument(); expect(screen.getByTestId('emptylist')).toBeInTheDocument();
expect( expect(screen.getByText('No transactions found')).toBeInTheDocument();
screen.getByText('This chain has 0 transactions')
).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('Cannot fetch transaction')).toBeInTheDocument();
}); });
it('item renders data of n length into list of n length', () => { it('item renders data of n length into list of n length', () => {
@ -73,6 +58,7 @@ describe('Txs infinite list', () => {
const txs = generateTxs(7); const txs = generateTxs(7);
render( render(
<MemoryRouter> <MemoryRouter>
<MockedProvider>
<TxsInfiniteList <TxsInfiniteList
txs={txs} txs={txs}
areTxsLoading={false} areTxsLoading={false}
@ -80,78 +66,14 @@ describe('Txs infinite list', () => {
loadMoreTxs={() => null} loadMoreTxs={() => null}
error={undefined} error={undefined}
/> />
</MockedProvider>
</MemoryRouter> </MemoryRouter>
); );
expect( expect(
screen screen
.getByTestId('infinite-scroll-wrapper') .getByTestId('transactions-list')
.querySelectorAll('.txs-infinite-list-item') .querySelectorAll('.transaction-row')
).toHaveLength(7); ).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(
<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(14);
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: 2000 },
});
});
expect(callback.mock.calls.length).toEqual(1);
});
}); });

View File

@ -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 { t } from '@vegaprotocol/i18n';
import { useScreenDimensions } from '@vegaprotocol/react-helpers';
import { TxsInfiniteListItem } from './txs-infinite-list-item'; import { TxsInfiniteListItem } from './txs-infinite-list-item';
import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response';
import EmptyList from '../empty-list/empty-list'; import EmptyList from '../empty-list/empty-list';
@ -11,39 +7,20 @@ import { Loader } from '@vegaprotocol/ui-toolkit';
interface TxsInfiniteListProps { interface TxsInfiniteListProps {
hasMoreTxs: boolean; hasMoreTxs: boolean;
areTxsLoading: boolean | undefined; areTxsLoading: boolean | undefined;
txs: BlockExplorerTransactionResult[] | undefined; txs: BlockExplorerTransactionResult[];
loadMoreTxs: () => void; loadMoreTxs: () => void;
error: Error | undefined; error: Error | undefined;
className?: string; className?: string;
hasFilters?: boolean;
} }
interface ItemProps { interface ItemProps {
index: BlockExplorerTransactionResult; tx: BlockExplorerTransactionResult;
style: React.CSSProperties;
isLoading: boolean;
error: Error | undefined;
} }
// eslint-disable-next-line @typescript-eslint/no-empty-function const Item = ({ tx }: ItemProps) => {
const NOOP = () => {}; const { hash, submitter, type, command, block, code, index: blockIndex } = tx;
return (
const Item = ({ index, style, isLoading, error }: ItemProps) => {
let content;
if (error) {
content = t(`Cannot fetch transaction`);
} else if (isLoading) {
content = <Loader />;
} else {
const {
hash,
submitter,
type,
command,
block,
code,
index: blockIndex,
} = index;
content = (
<TxsInfiniteListItem <TxsInfiniteListItem
type={type} type={type}
code={code} code={code}
@ -54,39 +31,22 @@ const Item = ({ index, style, isLoading, error }: ItemProps) => {
index={blockIndex} index={blockIndex}
/> />
); );
}
return <div style={style}>{content}</div>;
}; };
export const TxsInfiniteList = ({ export const TxsInfiniteList = ({
hasMoreTxs,
areTxsLoading, areTxsLoading,
txs, txs,
loadMoreTxs,
error,
className, className,
hasFilters = false,
}: TxsInfiniteListProps) => { }: TxsInfiniteListProps) => {
const { screenSize } = useScreenDimensions(); if (!txs || txs.length === 0) {
const isStacked = ['xs', 'sm'].includes(screenSize);
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const hasMountedRef = useRef(false);
useEffect(() => {
if (hasMountedRef.current) {
if (infiniteLoaderRef.current) {
infiniteLoaderRef.current.resetloadMoreItemsCache(true);
}
}
hasMountedRef.current = true;
}, [loadMoreTxs]);
if (!txs) {
if (!areTxsLoading) { if (!areTxsLoading) {
return ( return (
<EmptyList <EmptyList
heading={t('This chain has 0 transactions')} heading={t('No transactions found')}
label={t('Check back soon')} label={
hasFilters ? t('Try a different filter') : t('Check back soon')
}
/> />
); );
} else { } 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 ( return (
<div className={className} data-testid="transactions-list"> <div className="overflow-scroll">
<div className="lg:grid grid-cols-10 w-full mb-3 hidden text-vega-dark-300 uppercase"> <table className={className} data-testid="transactions-list">
<div className="col-span-3"> <thead>
<span className="hidden xl:inline">{t('Transaction')} &nbsp;</span> <tr className="w-full mb-3 text-vega-dark-300 uppercase text-left">
<th>
<span className="hidden xl:inline">{t('Txn')} &nbsp;</span>
<span>ID</span> <span>ID</span>
</div> </th>
<div className="col-span-3">{t('Submitted By')}</div> <th>{t('Type')}</th>
<div className="col-span-2">{t('Type')}</div> <th className="text-left">{t('From')}</th>
<div className="col-span-1">{t('Block')}</div> <th>{t('Block')}</th>
<div className="col-span-1">{t('Success')}</div> </tr>
</div> </thead>
<div data-testid="infinite-scroll-wrapper"> <tbody>
<InfiniteLoader {txs.map((t) => (
isItemLoaded={isItemLoaded} <Item key={t.hash} tx={t} />
itemCount={itemCount} ))}
loadMoreItems={loadMoreItems} </tbody>
ref={infiniteLoaderRef} </table>
>
{({ onItemsRendered, ref }) => (
<List
className="List"
height={995}
itemCount={itemCount}
itemSize={isStacked ? 134 : 50}
onItemsRendered={onItemsRendered}
ref={ref}
width={'100%'}
>
{({ index, style }) => (
<Item
index={txs[index]}
style={style}
isLoading={!isItemLoaded(index)}
error={error}
/>
)}
</List>
)}
</InfiniteLoader>
</div>
</div> </div>
); );
}; };

View File

@ -1,23 +1,17 @@
import { Routes } from '../../routes/route-names'; import { Table, TableRow } from '../table';
import { TruncatedLink } from '../truncate/truncated-link';
import { TxOrderType } from './tx-order-type';
import { Table, TableRow, TableCell } from '../table';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useFetch } from '@vegaprotocol/react-helpers'; import { useFetch } from '@vegaprotocol/react-helpers';
import type { BlockExplorerTransactions } from '../../routes/types/block-explorer-response'; 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 { getTxsDataUrl } from '../../hooks/use-txs-data';
import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit';
import EmptyList from '../empty-list/empty-list'; import EmptyList from '../empty-list/empty-list';
import { TxsInfiniteListItem } from './txs-infinite-list-item';
interface TxsPerBlockProps { interface TxsPerBlockProps {
blockHeight: string; blockHeight: string;
txCount: number; txCount: number;
} }
const truncateLength = 5;
export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => { export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => {
const filters = `filters[block.height]=${blockHeight}`; const filters = `filters[block.height]=${blockHeight}`;
const url = getTxsDataUrl({ limit: txCount.toString(), filters }); const url = getTxsDataUrl({ limit: txCount.toString(), filters });
@ -33,53 +27,23 @@ export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => {
<thead> <thead>
<TableRow modifier="bordered" className="font-mono"> <TableRow modifier="bordered" className="font-mono">
<td>{t('Transaction')}</td> <td>{t('Transaction')}</td>
<td>{t('From')}</td>
<td>{t('Type')}</td> <td>{t('Type')}</td>
<td>{t('Status')}</td> <td>{t('From')}</td>
<td>{t('Block')}</td>
</TableRow> </TableRow>
</thead> </thead>
<tbody> <tbody>
{data.transactions.map( {data.transactions.map(
({ hash, submitter, type, command, code }) => { ({ hash, submitter, type, command, code, block }) => {
return ( return (
<TableRow <TxsInfiniteListItem
modifier="bordered" block={block}
key={hash} hash={hash}
data-testid="transaction-row" submitter={submitter}
> type={type}
<TableCell command={command}
modifier="bordered" code={code}
className="pr-12 font-mono"
>
<TruncatedLink
to={`/${Routes.TX}/${hash}`}
text={hash}
startChars={truncateLength}
endChars={truncateLength}
/> />
</TableCell>
<TableCell
modifier="bordered"
className="pr-12 font-mono"
>
<TruncatedLink
to={`/${Routes.PARTIES}/${submitter}`}
text={submitter}
startChars={truncateLength}
endChars={truncateLength}
/>
</TableCell>
<TableCell modifier="bordered">
<TxOrderType orderType={type} command={command} />
</TableCell>
<TableCell modifier="bordered" className="text">
{isNumber(code) ? (
<ChainResponseCode code={code} hideLabel={true} />
) : (
code
)}
</TableCell>
</TableRow>
); );
} }
)} )}

View File

@ -56,10 +56,14 @@ export function VoteIcon({
return ( return (
<div <div
className={`voteicon inline-block my-1 py-1 px-2 py rounded-md text-white leading-one sm align-top ${bg}`} className={`voteicon inline-block py-0 px-2 py rounded-md text-white whitespace-nowrap leading-tight sm align-top ${bg}`}
> >
<Icon name={icon} size={3} className={`mr-2 p-0 fill-${fill}`} /> <Icon
<span className={`text-base text-${text}`} data-testid="label"> name={icon}
size={3}
className={`mr-2 p-0 mb-[-1px] fill-${fill}`}
/>
<span className={`text-${text}`} data-testid="label">
{label} {label}
</span> </span>
</div> </div>

View File

@ -10,16 +10,18 @@ import isNumber from 'lodash/isNumber';
export interface TxsStateProps { export interface TxsStateProps {
txsData: BlockExplorerTransactionResult[]; txsData: BlockExplorerTransactionResult[];
hasMoreTxs: boolean; hasMoreTxs: boolean;
lastCursor: string; cursor: string;
previousCursors: string[];
hasPreviousPage: boolean;
} }
export interface IUseTxsData { export interface IUseTxsData {
limit?: number; limit: number;
filters?: string; filters?: string;
} }
interface IGetTxsDataUrl { interface IGetTxsDataUrl {
limit?: string; limit: string;
filters?: string; filters?: string;
} }
@ -40,63 +42,89 @@ export const getTxsDataUrl = ({ limit, filters }: IGetTxsDataUrl) => {
}; };
export const useTxsData = ({ limit, filters }: IUseTxsData) => { export const useTxsData = ({ limit, filters }: IUseTxsData) => {
const [{ txsData, hasMoreTxs, lastCursor }, setTxsState] = const [
useState<TxsStateProps>({ { txsData, hasMoreTxs, cursor, previousCursors, hasPreviousPage },
setTxsState,
] = useState<TxsStateProps>({
txsData: [], txsData: [],
hasMoreTxs: true, hasMoreTxs: false,
lastCursor: '', previousCursors: [],
cursor: '',
hasPreviousPage: false,
}); });
const url = getTxsDataUrl({ limit: limit?.toString(), filters }); const url = getTxsDataUrl({ limit: limit.toString(), filters });
const { const {
state: { data, error, loading }, state: { data, error, loading },
refetch, refetch,
} = useFetch<BlockExplorerTransactions>(url, {}, false); } = useFetch<BlockExplorerTransactions>(url, {}, true);
useEffect(() => { useEffect(() => {
if (data && isNumber(data?.transactions?.length)) { if (!loading && data && isNumber(data.transactions.length)) {
setTxsState((prev) => ({ setTxsState((prev) => {
txsData: [...prev.txsData, ...data.transactions], return {
hasMoreTxs: data.transactions.length > 0, ...prev,
lastCursor: txsData: data.transactions,
data.transactions[data.transactions.length - 1]?.cursor || '', hasMoreTxs: data.transactions.length >= limit,
})); cursor: data?.transactions.at(-1)?.cursor || '',
} };
}, [setTxsState, data]);
useEffect(() => {
setTxsState((prev) => ({
txsData: [],
hasMoreTxs: true,
lastCursor: '',
}));
}, [filters]);
const loadTxs = useCallback(() => {
return refetch({
limit: limit,
before: lastCursor,
}); });
}, [lastCursor, limit, refetch]); }
}, [loading, setTxsState, data, limit]);
const nextPage = useCallback(() => {
const c = data?.transactions.at(0)?.cursor;
const newPreviousCursors = c ? [...previousCursors, c] : previousCursors;
const refreshTxs = useCallback(async () => {
setTxsState((prev) => ({ setTxsState((prev) => ({
...prev, ...prev,
lastCursor: '', hasPreviousPage: true,
hasMoreTxs: true, previousCursors: newPreviousCursors,
txsData: [],
})); }));
}, [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 { return {
data, txsData,
loading, loading,
error, error,
txsData,
hasMoreTxs, hasMoreTxs,
lastCursor, hasPreviousPage,
previousCursors,
cursor,
refreshTxs, refreshTxs,
loadTxs, nextPage,
previousPage,
}; };
}; };

View File

@ -21,6 +21,7 @@ import {
import { Footer } from '../components/footer/footer'; import { Footer } from '../components/footer/footer';
import { Header } from '../components/header'; import { Header } from '../components/header';
import { Routes } from './route-names'; import { Routes } from './route-names';
import { useExplorerNodeNamesLazyQuery } from './validators/__generated__/NodeNames';
const DialogsContainer = () => { const DialogsContainer = () => {
const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore(); const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore();
@ -39,6 +40,7 @@ export const Layout = () => {
const isHome = Boolean(useMatch(Routes.HOME)); const isHome = Boolean(useMatch(Routes.HOME));
const { ANNOUNCEMENTS_CONFIG_URL } = useEnvironment(); const { ANNOUNCEMENTS_CONFIG_URL } = useEnvironment();
const fixedWidthClasses = 'w-full max-w-[1500px] mx-auto'; const fixedWidthClasses = 'w-full max-w-[1500px] mx-auto';
useExplorerNodeNamesLazyQuery();
return ( return (
<> <>
@ -49,7 +51,7 @@ export const Layout = () => {
'grid grid-rows-[auto_1fr_auto] grid-cols-1', 'grid grid-rows-[auto_1fr_auto] grid-cols-1',
'border-vega-light-200 dark:border-vega-dark-200', 'border-vega-light-200 dark:border-vega-dark-200',
'antialiased text-black dark:text-white', 'antialiased text-black dark:text-white',
'overflow-hidden relative' 'relative'
)} )}
> >
<div> <div>

View File

@ -2,12 +2,15 @@ import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { SourceType } from './oracle'; import type { SourceType } from './oracle';
import { OracleSigners } from './oracle-signers'; import { OracleSigners } from './oracle-signers';
import { MockedProvider } from '@apollo/client/testing';
function renderComponent(sourceType: SourceType) { function renderComponent(sourceType: SourceType) {
return ( return (
<MockedProvider>
<MemoryRouter> <MemoryRouter>
<OracleSigners sourceType={sourceType} /> <OracleSigners sourceType={sourceType} />
</MemoryRouter> </MemoryRouter>
</MockedProvider>
); );
} }

View File

@ -1,6 +1,6 @@
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useScreenDimensions } from '@vegaprotocol/react-helpers'; import { useScreenDimensions } from '@vegaprotocol/react-helpers';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { SubHeading } from '../../../components/sub-heading'; import { SubHeading } from '../../../components/sub-heading';
import { toNonHex } from '../../../components/search/detect-search'; 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 { PartyBlockAccounts } from './components/party-block-accounts';
import { isValidPartyId } from './components/party-id-error'; import { isValidPartyId } from './components/party-id-error';
import { useDataProvider } from '@vegaprotocol/data-provider'; 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 Party = () => {
const [filters, setFilters] = useState(new Set(AllFilterOptions));
const { party } = useParams<{ party: string }>(); const { party } = useParams<{ party: string }>();
useDocumentTitle(['Public keys', party || '-']); useDocumentTitle(['Public keys', party || '-']);
@ -24,10 +27,24 @@ const Party = () => {
const partyId = toNonHex(party ? party : ''); const partyId = toNonHex(party ? party : '');
const { isMobile } = useScreenDimensions(); const { isMobile } = useScreenDimensions();
const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]); const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]);
const filters = `filters[tx.submitter]=${partyId}`; const baseFilters = `filters[tx.submitter]=${partyId}`;
const { hasMoreTxs, loadTxs, error, txsData, loading } = useTxsData({ const f =
limit: 10, filters && filters.size === 1
filters, ? `${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]); const variables = useMemo(() => ({ partyId }), [partyId]);
@ -81,14 +98,24 @@ const Party = () => {
</div> </div>
<SubHeading>{t('Transactions')}</SubHeading> <SubHeading>{t('Transactions')}</SubHeading>
<TxsListNavigation
refreshTxs={refreshTxs}
nextPage={nextPage}
previousPage={previousPage}
hasPreviousPage={hasPreviousPage}
loading={loading}
hasMoreTxs={hasMoreTxs}
>
<TxsFilter filters={filters} setFilters={setFilters} />
</TxsListNavigation>
{!error && txsData ? ( {!error && txsData ? (
<TxsInfiniteList <TxsInfiniteList
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
areTxsLoading={loading} areTxsLoading={loading}
txs={txsData} txs={txsData}
loadMoreTxs={loadTxs} loadMoreTxs={nextPage}
error={error} error={error}
className="mb-28" className="mb-28 w-full"
/> />
) : ( ) : (
<Splash> <Splash>

View File

@ -1,14 +1,14 @@
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { RouteTitle } from '../../../components/route-title'; import { RouteTitle } from '../../../components/route-title';
import { BlocksRefetch } from '../../../components/blocks';
import { TxsInfiniteList } from '../../../components/txs'; import { TxsInfiniteList } from '../../../components/txs';
import { useTxsData } from '../../../hooks/use-txs-data'; import { useTxsData } from '../../../hooks/use-txs-data';
import { useDocumentTitle } from '../../../hooks/use-document-title'; import { useDocumentTitle } from '../../../hooks/use-document-title';
import { useState } from 'react'; import { useState } from 'react';
import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter'; 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 = () => { export const TxsList = () => {
useDocumentTitle(['Transactions']); 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 = () => { export const TxsListFiltered = () => {
const [filters, setFilters] = useState(new Set(AllFilterOptions)); const [filters, setFilters] = useState(new Set(AllFilterOptions));
@ -29,26 +34,40 @@ export const TxsListFiltered = () => {
? `filters[cmd.type]=${Array.from(filters)[0]}` ? `filters[cmd.type]=${Array.from(filters)[0]}`
: ''; : '';
const { hasMoreTxs, loadTxs, error, txsData, refreshTxs, loading } = const {
useTxsData({ hasMoreTxs,
nextPage,
previousPage,
error,
refreshTxs,
loading,
txsData,
hasPreviousPage,
} = useTxsData({
limit: BE_TXS_PER_REQUEST, limit: BE_TXS_PER_REQUEST,
filters: f, filters: f,
}); });
return ( return (
<> <>
<menu className="mb-2"> <TxsListNavigation
<BlocksRefetch refetch={refreshTxs} /> refreshTxs={refreshTxs}
nextPage={nextPage}
previousPage={previousPage}
hasPreviousPage={hasPreviousPage}
loading={loading}
hasMoreTxs={hasMoreTxs}
>
<TxsFilter filters={filters} setFilters={setFilters} /> <TxsFilter filters={filters} setFilters={setFilters} />
</menu> </TxsListNavigation>
<TxsInfiniteList <TxsInfiniteList
hasFilters={filters.size > 0}
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
areTxsLoading={loading} areTxsLoading={loading}
txs={txsData} txs={txsData}
loadMoreTxs={loadTxs} loadMoreTxs={nextPage}
error={error} error={error}
className="mb-28" className="mb-28 w-full min-w-[400px]"
/> />
</> </>
); );

View File

@ -1,10 +1,11 @@
import { BrowserRouter as Router } from 'react-router-dom';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { TxDetails } from './tx-details'; import { TxDetails } from './tx-details';
import type { import type {
BlockExplorerTransactionResult, BlockExplorerTransactionResult,
ValidatorHeartbeat, ValidatorHeartbeat,
} from '../../../routes/types/block-explorer-response'; } 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 // Note: Long enough that there is a truncated output and a full output
const pubKey = const pubKey =
@ -27,9 +28,11 @@ const txData: BlockExplorerTransactionResult = {
}; };
const renderComponent = (txData: BlockExplorerTransactionResult) => ( const renderComponent = (txData: BlockExplorerTransactionResult) => (
<Router> <MemoryRouter>
<MockedProvider>
<TxDetails txData={txData} pubKey={pubKey} /> <TxDetails txData={txData} pubKey={pubKey} />
</Router> </MockedProvider>
</MemoryRouter>
); );
describe('Transaction details', () => { describe('Transaction details', () => {

View File

@ -0,0 +1,13 @@
query ExplorerNodeNames {
nodesConnection {
edges {
node {
id
name
pubkey
tmPubkey
ethereumAddress
}
}
}
}

View File

@ -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<ExplorerNodeNamesQuery, ExplorerNodeNamesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerNodeNamesQuery, ExplorerNodeNamesQueryVariables>(ExplorerNodeNamesDocument, options);
}
export function useExplorerNodeNamesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerNodeNamesQuery, ExplorerNodeNamesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerNodeNamesQuery, ExplorerNodeNamesQueryVariables>(ExplorerNodeNamesDocument, options);
}
export type ExplorerNodeNamesQueryHookResult = ReturnType<typeof useExplorerNodeNamesQuery>;
export type ExplorerNodeNamesLazyQueryHookResult = ReturnType<typeof useExplorerNodeNamesLazyQuery>;
export type ExplorerNodeNamesQueryResult = Apollo.QueryResult<ExplorerNodeNamesQuery, ExplorerNodeNamesQueryVariables>;

View File

@ -1,12 +1,12 @@
import { Button } from '../button'; import { Button } from '../button';
import { t } from '@vegaprotocol/i18n'; 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; if (address.length < 11) return address;
return ( return (
address.slice(0, 6) + address.slice(0, start) +
'\u2026' + '\u2026' +
address.slice(address.length - 4, address.length) address.slice(address.length - end, address.length)
); );
} }

View File

@ -16,7 +16,8 @@ describe('truncateByChars', () => {
}, },
{ i: '12345678901234567890', s: 0, e: 10, o: `${ELLIPSIS}1234567890` }, { i: '12345678901234567890', s: 0, e: 10, o: `${ELLIPSIS}1234567890` },
{ i: '123', s: 0, e: 4, o: '123' }, { 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); expect(truncateByChars(i, s, e)).toStrictEqual(o);
}); });
}); });
@ -28,7 +29,7 @@ describe('shorten', () => {
{ i: '12345678901234567890', l: 10, o: `123456789${ELLIPSIS}` }, { i: '12345678901234567890', l: 10, o: `123456789${ELLIPSIS}` },
{ i: '12345678901234567890', l: 20, o: `1234567890123456789${ELLIPSIS}` }, { i: '12345678901234567890', l: 20, o: `1234567890123456789${ELLIPSIS}` },
{ i: '12345678901234567890', l: 30, o: `12345678901234567890` }, { 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); const output = shorten(i, l);
expect(output).toStrictEqual(o); expect(output).toStrictEqual(o);
}); });
@ -51,7 +52,7 @@ describe('titlefy', () => {
words: ['VEGAUSD', '123.22'], words: ['VEGAUSD', '123.22'],
o: 'VEGAUSD - 123.22 - Vega', 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); expect(titlefy(words)).toEqual(o);
}); });
}); });

View File

@ -6,7 +6,10 @@ export function truncateByChars(input: string, start = 6, end = 6) {
if (input.length <= start + end + 1) { if (input.length <= start + end + 1) {
return input; 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) { export function shorten(input: string, limit?: number) {