feat(explorer): transaction list filtering

This commit is contained in:
Edd 2023-06-15 17:10:17 +01:00 committed by GitHub
parent 6be117086a
commit 5a90ebe12e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 725 additions and 575 deletions

View File

@ -5,7 +5,7 @@ NX_SENTRY_DSN=https://b3a56b03eda842faad731f3ea9dfd1bc@o286262.ingest.sentry.io/
NX_VEGA_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/networks/master/mainnet1/mainnet1.toml
NX_VEGA_URL=https://api.vega.community/graphql
NX_VEGA_ENV=MAINNET
NX_BLOCK_EXPLORER=https://be.vega.community/rest/
NX_BLOCK_EXPLORER=https://be.vega.community/rest
NX_ETHERSCAN_URL=https://etherscan.io
NX_VEGA_GOVERNANCE_URL=https://governance.vega.xyz
NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/mainnet/announcements.json

View File

@ -70,7 +70,7 @@
"executor": "@nrwl/workspace:run-commands",
"options": {
"commands": [
"npx openapi-typescript https://raw.githubusercontent.com/vegaprotocol/documentation/main/specs/v0.67.3/blockexplorer.openapi.json --output apps/explorer/src/types/explorer.d.ts --immutable-types"
"npx openapi-typescript https://raw.githubusercontent.com/vegaprotocol/documentation/main/specs/v0.71.4/blockexplorer.openapi.json --output apps/explorer/src/types/explorer.d.ts --immutable-types"
]
}
},

View File

@ -1,29 +1,13 @@
import WS from 'jest-websocket-mock';
import useWebSocket from 'react-use-websocket';
import {
render,
screen,
fireEvent,
act,
waitFor,
} from '@testing-library/react';
import { TendermintWebsocketContext } from '../../contexts/websocket/tendermint-websocket-context';
import { render, screen } from '@testing-library/react';
import { BlocksRefetch } from './blocks-refetch';
const BlocksRefetchInWebsocketProvider = ({
callback,
mocketLocation,
}: {
callback: () => null;
mocketLocation: string;
}) => {
const contextShape = useWebSocket(mocketLocation);
return (
<TendermintWebsocketContext.Provider value={{ ...contextShape }}>
<BlocksRefetch refetch={callback} />
</TendermintWebsocketContext.Provider>
);
return <BlocksRefetch refetch={callback} />;
};
describe('Blocks refetch', () => {
@ -32,111 +16,8 @@ describe('Blocks refetch', () => {
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
render(
<BlocksRefetchInWebsocketProvider
callback={() => null}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
expect(screen.getByTestId('new-blocks')).toHaveTextContent('new blocks');
render(<BlocksRefetchInWebsocketProvider callback={() => null} />);
expect(screen.getByTestId('refresh')).toBeInTheDocument();
mocket.close();
});
it('should initiate callback when the button is clicked', async () => {
const mocketLocation = 'wss:localhost:3003';
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
const callback = jest.fn();
render(
<BlocksRefetchInWebsocketProvider
callback={callback}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
const button = screen.getByTestId('refresh');
act(() => {
fireEvent.click(button);
});
expect(callback.mock.calls.length).toEqual(1);
mocket.close();
});
it('should show new blocks as websocket is correctly updated', async () => {
const mocketLocation = 'wss:localhost:3004';
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
render(
<BlocksRefetchInWebsocketProvider
callback={() => null}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
// Ensuring we send an ID equal to the one the client subscribed with.
await waitFor(() => expect(mocket.messages.length).toEqual(1));
// @ts-ignore id on messages
const id = mocket.messages[0].id;
const newBlockMessage = {
id,
result: {
query: "tm.event = 'NewBlock'",
},
};
expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks');
act(() => {
mocket.send(newBlockMessage);
});
expect(screen.getByTestId('new-blocks')).toHaveTextContent('1 new blocks');
act(() => {
mocket.send(newBlockMessage);
});
expect(screen.getByTestId('new-blocks')).toHaveTextContent('2 new blocks');
mocket.close();
});
it('will not show new blocks if websocket has wrong ID', async () => {
const mocketLocation = 'wss:localhost:3005';
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
render(
<BlocksRefetchInWebsocketProvider
callback={() => null}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
// Ensuring we send an ID equal to the one the client subscribed with.
await waitFor(() => expect(mocket.messages.length).toEqual(1));
const newBlockMessageBadId = {
id: 'blahblahblah',
result: {
query: "tm.event = 'NewBlock'",
},
};
expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks');
act(() => {
mocket.send(newBlockMessageBadId);
});
expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks');
mocket.close();
});
});

View File

@ -1,36 +1,19 @@
import { useState, useEffect } from 'react';
import { useTendermintWebsocket } from '../../hooks/use-tendermint-websocket';
import { t } from '@vegaprotocol/i18n';
import { ButtonLink } from '@vegaprotocol/ui-toolkit';
import { Button, Icon } from '@vegaprotocol/ui-toolkit';
interface BlocksRefetchProps {
refetch: () => void;
}
export const BlocksRefetch = ({ refetch }: BlocksRefetchProps) => {
const [blocksToLoad, setBlocksToLoad] = useState<number>(0);
const { messages } = useTendermintWebsocket({
query: "tm.event = 'NewBlock'",
});
useEffect(() => {
if (messages.length > 0) {
setBlocksToLoad((prev) => prev + 1);
}
}, [messages]);
const refresh = () => {
refetch();
setBlocksToLoad(0);
};
return (
<div className="mb-4">
<span data-testid="new-blocks">{blocksToLoad} new blocks - </span>
<ButtonLink onClick={refresh} data-testid="refresh">
{t('refresh to see latest')}
</ButtonLink>
</div>
<Button onClick={refresh} data-testid="refresh" size="xs">
<Icon name="refresh" className="!align-baseline mr-2" size={3} />
{t('Load new')}
</Button>
);
};

View File

@ -0,0 +1,24 @@
import { t } from '@vegaprotocol/i18n';
export interface FilterLabelProps {
filters: Set<string>;
}
/**
* Renders the list (currently limited to 1) of filters set by the
* Transaction Filter
*/
export function FilterLabel({ filters }: FilterLabelProps) {
if (!filters || filters.size !== 1) {
return <span className="uppercase">{t('Filter')}</span>;
}
return (
<div>
<span className="uppercase">{t('Filters')}:</span>&nbsp;
<code className="bg-vega-light-150 px-2 rounded-md capitalize">
{Array.from(filters)[0]}
</code>
</div>
);
}

View File

@ -0,0 +1,164 @@
import { t } from '@vegaprotocol/i18n';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenuSubContent,
Icon,
Button,
} from '@vegaprotocol/ui-toolkit';
import type { Dispatch, SetStateAction } from 'react';
import { FilterLabel } from './tx-filter-label';
// All possible transaction types. Should be generated.
export type FilterOption =
| 'Amend LiquidityProvision Order'
| 'Amend Order'
| 'Batch Market Instructions'
| 'Cancel LiquidityProvision Order'
| 'Cancel Order'
| 'Cancel Transfer Funds'
| 'Chain Event'
| 'Delegate'
| 'Ethereum Key Rotate Submission'
| 'Issue Signatures'
| 'Key Rotate Submission'
| 'Liquidity Provision Order'
| 'Node Signature'
| 'Node Vote'
| 'Proposal'
| 'Protocol Upgrade'
| 'Register new Node'
| 'State Variable Proposal'
| 'Submit Oracle Data'
| 'Submit Order'
| 'Transfer Funds'
| 'Undelegate'
| 'Validator Heartbeat'
| 'Vote on Proposal'
| 'Withdraw';
// Alphabetised list of transaction types to appear at the top level
export const PrimaryFilterOptions: FilterOption[] = [
'Amend LiquidityProvision Order',
'Amend Order',
'Batch Market Instructions',
'Cancel LiquidityProvision Order',
'Cancel Order',
'Cancel Transfer Funds',
'Delegate',
'Liquidity Provision Order',
'Proposal',
'Submit Oracle Data',
'Submit Order',
'Transfer Funds',
'Undelegate',
'Vote on Proposal',
'Withdraw',
];
// Alphabetised list of transaction types to nest under a 'More...' submenu
export const SecondaryFilterOptions: FilterOption[] = [
'Chain Event',
'Ethereum Key Rotate Submission',
'Issue Signatures',
'Key Rotate Submission',
'Node Signature',
'Node Vote',
'Protocol Upgrade',
'Register new Node',
'State Variable Proposal',
'Validator Heartbeat',
];
export const AllFilterOptions: FilterOption[] = [
...PrimaryFilterOptions,
...SecondaryFilterOptions,
];
export interface TxFilterProps {
filters: Set<FilterOption>;
setFilters: Dispatch<SetStateAction<Set<FilterOption>>>;
}
/**
* Renders a structured dropdown menu of all of the available transaction
* 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 setFilters A function to update the filters prop
* @returns
*/
export const TxsFilter = ({ filters, setFilters }: TxFilterProps) => {
return (
<DropdownMenu
modal={false}
trigger={
<DropdownMenuTrigger className="ml-2">
<Button size="xs">
<FilterLabel filters={filters} />
</Button>
</DropdownMenuTrigger>
}
>
<DropdownMenuContent>
{filters.size > 1 ? null : (
<>
<DropdownMenuCheckboxItem
onCheckedChange={() => setFilters(new Set(AllFilterOptions))}
>
{t('Clear filters')} <Icon name="cross" />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
</>
)}
{PrimaryFilterOptions.map((f) => (
<DropdownMenuCheckboxItem
key={f}
checked={filters.has(f)}
onCheckedChange={() => {
// NOTE: These act like radio buttons until the API supports multiple filters
setFilters(new Set([f]));
}}
id={`radio-${f}`}
>
{f}
<DropdownMenuItemIndicator>
<Icon name="tick-circle" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{t('More Types')}
<Icon name="chevron-right" />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SecondaryFilterOptions.map((f) => (
<DropdownMenuCheckboxItem
key={f}
checked={filters.has(f)}
onCheckedChange={(checked) => {
// NOTE: These act like radio buttons until the API supports multiple filters
setFilters(new Set([f]));
}}
id={`radio-${f}`}
>
{f}
<DropdownMenuItemIndicator>
<Icon name="tick-circle" className="inline" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -1,4 +1,4 @@
import React from 'react';
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';
@ -69,6 +69,17 @@ export const TxsInfiniteList = ({
}: TxsInfiniteListProps) => {
const { screenSize } = useScreenDimensions();
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) {
@ -110,6 +121,7 @@ export const TxsInfiniteList = ({
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
ref={infiniteLoaderRef}
>
{({ onItemsRendered, ref }) => (
<List

View File

@ -33,7 +33,7 @@ export const getTxsDataUrl = ({ limit, filters }: IGetTxsDataUrl) => {
// Hacky fix for param as array
let urlAsString = url.toString();
if (filters) {
urlAsString += '&' + filters;
urlAsString += '&' + filters.replace(' ', '%20');
}
return urlAsString;
@ -65,6 +65,14 @@ export const useTxsData = ({ limit, filters }: IUseTxsData) => {
}
}, [setTxsState, data]);
useEffect(() => {
setTxsState((prev) => ({
txsData: [],
hasMoreTxs: true,
lastCursor: '',
}));
}, [filters]);
const loadTxs = useCallback(() => {
return refetch({
limit: limit,

View File

@ -5,17 +5,43 @@ import { TxsInfiniteList } from '../../../components/txs';
import { useTxsData } from '../../../hooks/use-txs-data';
import { useDocumentTitle } from '../../../hooks/use-document-title';
const BE_TXS_PER_REQUEST = 20;
import { useState } from 'react';
import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter';
const BE_TXS_PER_REQUEST = 15;
export const TxsList = () => {
useDocumentTitle(['Transactions']);
const { hasMoreTxs, loadTxs, error, txsData, refreshTxs, loading } =
useTxsData({ limit: BE_TXS_PER_REQUEST });
return (
<section className="md:p-2 lg:p-4 xl:p-6">
<section className="md:p-2 lg:p-4 xl:p-6 relative">
<RouteTitle>{t('Transactions')}</RouteTitle>
<BlocksRefetch refetch={refreshTxs} />
<TxsListFiltered />
</section>
);
};
export const TxsListFiltered = () => {
const [filters, setFilters] = useState(new Set(AllFilterOptions));
const f =
filters && filters.size === 1
? `filters[cmd.type]=${Array.from(filters)[0]}`
: '';
const { hasMoreTxs, loadTxs, error, txsData, refreshTxs, loading } =
useTxsData({
limit: BE_TXS_PER_REQUEST,
filters: f,
});
return (
<>
<menu className="mb-2">
<BlocksRefetch refetch={refreshTxs} />
<TxsFilter filters={filters} setFilters={setFilters} />
</menu>
<TxsInfiniteList
hasMoreTxs={hasMoreTxs}
areTxsLoading={loading}
@ -24,6 +50,6 @@ export const TxsList = () => {
error={error}
className="mb-28"
/>
</section>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -161,6 +161,51 @@ export const DropdownMenuSeparator = forwardRef<
/>
));
/**
* Container element for submenus
*/
export const DropdownMenuSub = forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Sub>,
React.ComponentProps<typeof DropdownMenuPrimitive.Sub>
>(({ ...subProps }) => <DropdownMenuPrimitive.Sub {...subProps} />);
/**
* Container within a DropdownMenuSub specifically for the content
*/
export const DropdownMenuSubContent = forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...subContentProps }, forwardedRef) => (
<DropdownMenuPrimitive.SubContent
ref={forwardedRef}
className={classNames('bg-vega-light-150 dark:bg-vega-dark-150', className)}
{...subContentProps}
/>
));
/**
* Equivalent to trigger, but for triggering sub menus
*/
export const DropdownMenuSubTrigger = forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>
>(({ className, ...subTriggerProps }, forwardedRef) => (
<DropdownMenuPrimitive.SubTrigger
className={classNames(className, itemClass)}
ref={forwardedRef}
{...subTriggerProps}
/>
));
/**
* Portal to ensure menu portions are rendered outwith where they appear in the
* DOM.
*/
export const DropdownMenuPortal = forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Portal>,
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>
>(({ ...portalProps }) => <DropdownMenuPrimitive.Portal {...portalProps} />);
/**
* Wraps a regular DropdownMenuItem with copy to clip board functionality
*/