mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
Merge pull request #433 from blockscout/address-blocks-validated
address page: blocks validated
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { NextApiRequest } from 'next';
|
||||
|
||||
import getSearchParams from 'lib/api/getSearchParams';
|
||||
import handler from 'lib/api/handler';
|
||||
|
||||
const getUrl = (req: NextApiRequest) => {
|
||||
const searchParamsStr = getSearchParams(req);
|
||||
return `/v2/addresses/${ req.query.id }/blocks-validated${ searchParamsStr ? '?' + searchParamsStr : '' }`;
|
||||
};
|
||||
|
||||
const requestHandler = handler(getUrl, [ 'GET' ]);
|
||||
|
||||
export default requestHandler;
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Transaction } from 'types/api/transaction';
|
||||
|
||||
import type { AddressTag, WatchlistName } from './addressParams';
|
||||
import type { Block } from './block';
|
||||
import type { TokenInfo, TokenType } from './tokenInfo';
|
||||
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
|
||||
|
||||
@@ -75,3 +76,11 @@ export interface AddressCoinBalanceHistoryResponse {
|
||||
items_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddressBlocksValidatedResponse {
|
||||
items: Array<Block>;
|
||||
next_page_params: {
|
||||
block_number: number;
|
||||
items_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AddressTxsFilters,
|
||||
AddressTokenTransferFilters,
|
||||
AddressCoinBalanceHistoryResponse,
|
||||
AddressBlocksValidatedResponse,
|
||||
} from 'types/api/address';
|
||||
import type { BlocksResponse, BlockTransactionsResponse, BlockFilters } from 'types/api/block';
|
||||
import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
|
||||
@@ -26,7 +27,8 @@ export type PaginatedQueryKeys =
|
||||
QueryKeys.txInternals |
|
||||
QueryKeys.txLogs |
|
||||
QueryKeys.txTokenTransfers |
|
||||
QueryKeys.addressCoinBalanceHistory;
|
||||
QueryKeys.addressCoinBalanceHistory |
|
||||
QueryKeys.addressBlocksValidated;
|
||||
|
||||
export type PaginatedResponse<Q extends PaginatedQueryKeys> =
|
||||
Q extends QueryKeys.addressTxs ? AddressTransactionsResponse :
|
||||
@@ -39,7 +41,8 @@ export type PaginatedResponse<Q extends PaginatedQueryKeys> =
|
||||
Q extends QueryKeys.txLogs ? LogsResponse :
|
||||
Q extends QueryKeys.txTokenTransfers ? TokenTransferResponse :
|
||||
Q extends QueryKeys.addressCoinBalanceHistory ? AddressCoinBalanceHistoryResponse :
|
||||
never
|
||||
Q extends QueryKeys.addressBlocksValidated ? AddressBlocksValidatedResponse :
|
||||
never
|
||||
|
||||
export type PaginationFilters<Q extends PaginatedQueryKeys> =
|
||||
Q extends QueryKeys.addressTxs ? AddressTxsFilters :
|
||||
@@ -69,6 +72,7 @@ export const PAGINATION_FIELDS: PaginationFields = {
|
||||
[QueryKeys.txTokenTransfers]: [ 'block_number', 'items_count', 'transaction_hash', 'index' ],
|
||||
[QueryKeys.txLogs]: [ 'items_count', 'transaction_hash', 'index' ],
|
||||
[QueryKeys.addressCoinBalanceHistory]: [ 'items_count', 'block_number' ],
|
||||
[QueryKeys.addressBlocksValidated]: [ 'items_count', 'block_number' ],
|
||||
};
|
||||
|
||||
type PaginationFiltersFields = {
|
||||
@@ -79,6 +83,7 @@ export const PAGINATION_FILTERS_FIELDS: PaginationFiltersFields = {
|
||||
[QueryKeys.addressTxs]: [ 'filter' ],
|
||||
[QueryKeys.addressTokenTransfers]: [ 'filter', 'type' ],
|
||||
[QueryKeys.addressCoinBalanceHistory]: [],
|
||||
[QueryKeys.addressBlocksValidated]: [],
|
||||
[QueryKeys.blocks]: [ 'type' ],
|
||||
[QueryKeys.txsValidate]: [ 'filter', 'type', 'method' ],
|
||||
[QueryKeys.txsPending]: [ 'filter', 'type', 'method' ],
|
||||
|
||||
@@ -28,4 +28,5 @@ export enum QueryKeys {
|
||||
addressCoinBalanceHistoryByDay='address-coin-balance-history-by-day',
|
||||
addressTxs='addressTxs',
|
||||
addressTokenTransfers='addressTokenTransfers',
|
||||
addressBlocksValidated='address-blocks-validated',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Box, Hide, Show, Table, Tbody, Th, Tr } from '@chakra-ui/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type { SocketMessage } from 'lib/socket/types';
|
||||
import type { Address, AddressBlocksValidatedResponse } from 'types/api/address';
|
||||
import { QueryKeys } from 'types/client/queries';
|
||||
|
||||
import appConfig from 'configs/app/config';
|
||||
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
|
||||
import useSocketChannel from 'lib/socket/useSocketChannel';
|
||||
import useSocketMessage from 'lib/socket/useSocketMessage';
|
||||
import ActionBar from 'ui/shared/ActionBar';
|
||||
import DataFetchAlert from 'ui/shared/DataFetchAlert';
|
||||
import Pagination from 'ui/shared/Pagination';
|
||||
import SkeletonTable from 'ui/shared/SkeletonTable';
|
||||
import SocketAlert from 'ui/shared/SocketAlert';
|
||||
import { default as Thead } from 'ui/shared/TheadSticky';
|
||||
|
||||
import AddressBlocksValidatedListItem from './blocksValidated/AddressBlocksValidatedListItem';
|
||||
import AddressBlocksValidatedSkeletonMobile from './blocksValidated/AddressBlocksValidatedSkeletonMobile';
|
||||
import AddressBlocksValidatedTableItem from './blocksValidated/AddressBlocksValidatedTableItem';
|
||||
|
||||
interface Props {
|
||||
addressQuery: UseQueryResult<Address>;
|
||||
}
|
||||
|
||||
const AddressBlocksValidated = ({ addressQuery }: Props) => {
|
||||
const [ socketAlert, setSocketAlert ] = React.useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQueryWithPages({
|
||||
apiPath: `/node-api/addresses/${ addressQuery.data?.hash }/blocks-validated`,
|
||||
queryName: QueryKeys.addressBlocksValidated,
|
||||
options: {
|
||||
enabled: Boolean(addressQuery.data),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSocketError = React.useCallback(() => {
|
||||
setSocketAlert(true);
|
||||
}, []);
|
||||
|
||||
const handleNewSocketMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => {
|
||||
setSocketAlert(false);
|
||||
|
||||
queryClient.setQueryData(
|
||||
[ QueryKeys.addressBlocksValidated, { page: query.pagination.page } ],
|
||||
(prevData: AddressBlocksValidatedResponse | undefined) => {
|
||||
if (!prevData) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
items: [ payload.block, ...prevData.items ],
|
||||
};
|
||||
});
|
||||
}, [ query.pagination.page, queryClient ]);
|
||||
|
||||
const channel = useSocketChannel({
|
||||
topic: `blocks:${ addressQuery.data?.hash.toLowerCase() }`,
|
||||
onSocketClose: handleSocketError,
|
||||
onSocketError: handleSocketError,
|
||||
isDisabled: addressQuery.isLoading || addressQuery.isError || !addressQuery.data.hash || query.pagination.page !== 1,
|
||||
});
|
||||
useSocketMessage({
|
||||
channel,
|
||||
event: 'new_block',
|
||||
handler: handleNewSocketMessage,
|
||||
});
|
||||
|
||||
const content = (() => {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Hide below="lg">
|
||||
<SkeletonTable columns={ [ '17%', '17%', '16%', '25%', '25%' ] }/>
|
||||
</Hide>
|
||||
<Show below="lg">
|
||||
<AddressBlocksValidatedSkeletonMobile/>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return <DataFetchAlert/>;
|
||||
}
|
||||
|
||||
if (query.data.items.length === 0) {
|
||||
return 'There is no validated blocks for this address';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hide below="lg">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead top={ 80 }>
|
||||
<Tr>
|
||||
<Th width="17%">Block</Th>
|
||||
<Th width="17%">Age</Th>
|
||||
<Th width="16%">Txn</Th>
|
||||
<Th width="25%">GasUsed</Th>
|
||||
<Th width="25%" isNumeric>Reward { appConfig.network.currency.symbol }</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{ query.data.items.map((item) => (
|
||||
<AddressBlocksValidatedTableItem key={ item.height } { ...item } page={ query.pagination.page }/>
|
||||
)) }
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Hide>
|
||||
<Show below="lg">
|
||||
{ query.data.items.map((item) => (
|
||||
<AddressBlocksValidatedListItem key={ item.height } { ...item } page={ query.pagination.page }/>
|
||||
)) }
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
})();
|
||||
|
||||
const isPaginatorHidden = !query.isLoading && !query.isError && query.pagination.page === 1 && !query.pagination.hasNextPage;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{ !isPaginatorHidden && (
|
||||
<ActionBar mt={ -6 }>
|
||||
<Pagination ml="auto" { ...query.pagination }/>
|
||||
</ActionBar>
|
||||
) }
|
||||
{ socketAlert && <SocketAlert mb={ 6 }/> }
|
||||
{ content }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AddressBlocksValidated);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Link, Text, Flex } from '@chakra-ui/react';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import React from 'react';
|
||||
|
||||
import type { Block } from 'types/api/block';
|
||||
|
||||
import appConfig from 'configs/app/config';
|
||||
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
|
||||
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
|
||||
import link from 'lib/link/link';
|
||||
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
|
||||
import Utilization from 'ui/shared/Utilization/Utilization';
|
||||
|
||||
type Props = Block & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
const AddressBlocksValidatedListItem = (props: Props) => {
|
||||
const blockUrl = link('block', { id: String(props.height) });
|
||||
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
|
||||
const totalReward = getBlockTotalReward(props);
|
||||
|
||||
return (
|
||||
<AccountListItemMobile rowGap={ 2 }>
|
||||
<Flex justifyContent="space-between" w="100%">
|
||||
<Link href={ blockUrl } fontWeight="700">{ props.height }</Link>
|
||||
<Text variant="secondary">{ timeAgo }</Text>
|
||||
</Flex>
|
||||
<Flex columnGap={ 2 } w="100%">
|
||||
<Text fontWeight={ 500 } flexShrink={ 0 }>Txn</Text>
|
||||
<Text variant="secondary">{ props.tx_count }</Text>
|
||||
</Flex>
|
||||
<Flex columnGap={ 2 } w="100%">
|
||||
<Text fontWeight={ 500 } flexShrink={ 0 }>Gas used</Text>
|
||||
<Text variant="secondary">{ BigNumber(props.gas_used || 0).toFormat() }</Text>
|
||||
<Utilization colorScheme="gray" value={ BigNumber(props.gas_used || 0).dividedBy(BigNumber(props.gas_limit)).toNumber() }/>
|
||||
</Flex>
|
||||
<Flex columnGap={ 2 } w="100%">
|
||||
<Text fontWeight={ 500 } flexShrink={ 0 }>Reward { appConfig.network.currency.symbol }</Text>
|
||||
<Text variant="secondary">{ totalReward }</Text>
|
||||
</Flex>
|
||||
</AccountListItemMobile>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AddressBlocksValidatedListItem);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Skeleton, Flex, Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const AddressBlocksValidatedSkeletonMobile = () => {
|
||||
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{ Array.from(Array(2)).map((item, index) => (
|
||||
<Flex
|
||||
key={ index }
|
||||
rowGap={ 3 }
|
||||
flexDirection="column"
|
||||
paddingY={ 6 }
|
||||
borderTopWidth="1px"
|
||||
borderColor={ borderColor }
|
||||
_last={{
|
||||
borderBottomWidth: '1px',
|
||||
}}
|
||||
>
|
||||
<Flex justifyContent="space-between" w="100%" h={ 6 }>
|
||||
<Skeleton w="100px"/>
|
||||
<Skeleton w="100px"/>
|
||||
</Flex>
|
||||
<Flex h={ 6 } columnGap={ 2 }>
|
||||
<Skeleton w="40px"/>
|
||||
<Skeleton w="40px"/>
|
||||
</Flex>
|
||||
<Flex h={ 6 } columnGap={ 2 }>
|
||||
<Skeleton w="70px"/>
|
||||
<Skeleton w="70px"/>
|
||||
</Flex>
|
||||
<Flex h={ 6 } columnGap={ 2 }>
|
||||
<Skeleton w="100px"/>
|
||||
<Skeleton w="120px"/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressBlocksValidatedSkeletonMobile;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Link, Td, Tr, Text, Box, Flex } from '@chakra-ui/react';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import React from 'react';
|
||||
|
||||
import type { Block } from 'types/api/block';
|
||||
|
||||
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
|
||||
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
|
||||
import link from 'lib/link/link';
|
||||
import Utilization from 'ui/shared/Utilization/Utilization';
|
||||
|
||||
type Props = Block & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
const AddressBlocksValidatedTableItem = (props: Props) => {
|
||||
const blockUrl = link('block', { id: String(props.height) });
|
||||
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
|
||||
const totalReward = getBlockTotalReward(props);
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>
|
||||
<Link href={ blockUrl } fontWeight="700">{ props.height }</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text variant="secondary">{ timeAgo }</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontWeight="500">{ props.tx_count }</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<Flex alignItems="center" columnGap={ 2 }>
|
||||
<Box flexBasis="80px">{ BigNumber(props.gas_used || 0).toFormat() }</Box>
|
||||
<Utilization colorScheme="gray" value={ BigNumber(props.gas_used || 0).dividedBy(BigNumber(props.gas_limit)).toNumber() }/>
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td isNumeric display="flex" justifyContent="end">
|
||||
{ totalReward }
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AddressBlocksValidatedTableItem);
|
||||
@@ -23,7 +23,7 @@ const AddressCoinBalanceListItem = (props: Props) => {
|
||||
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
|
||||
|
||||
return (
|
||||
<AccountListItemMobile fontSize="sm" rowGap={ 2 }>
|
||||
<AccountListItemMobile rowGap={ 2 }>
|
||||
<Flex justifyContent="space-between" w="100%">
|
||||
<Text fontWeight={ 600 }>{ BigNumber(props.value).div(WEI).toFixed(8) } { appConfig.network.currency.symbol }</Text>
|
||||
<Stat flexGrow="0">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { QueryKeys } from 'types/client/queries';
|
||||
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
|
||||
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
|
||||
import AddressCoinBalance from 'ui/address/AddressCoinBalance';
|
||||
import AddressDetails from 'ui/address/AddressDetails';
|
||||
import AddressTxs from 'ui/address/AddressTxs';
|
||||
@@ -39,6 +40,9 @@ const AddressPageContent = () => {
|
||||
{ id: 'tokens', title: 'Tokens', component: null },
|
||||
{ id: 'internal_txn', title: 'Internal txn', component: null },
|
||||
{ id: 'coin_balance_history', title: 'Coin balance history', component: <AddressCoinBalance addressQuery={ addressQuery }/> },
|
||||
// temporary show this tab in all address
|
||||
// later api will return info about available tabs
|
||||
{ id: 'blocks_validated', title: 'Blocks validated', component: <AddressBlocksValidated addressQuery={ addressQuery }/> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,19 +40,6 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled }: Props) =>
|
||||
const scrollDirection = useScrollDirection();
|
||||
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(tabs.length + 1);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady) {
|
||||
let tabIndex = 0;
|
||||
if (router.query.tab) {
|
||||
tabIndex = tabs.findIndex(({ id }) => id === router.query.tab);
|
||||
if (tabIndex < 0) {
|
||||
tabIndex = 0;
|
||||
}
|
||||
}
|
||||
setActiveTabIndex(tabIndex);
|
||||
}
|
||||
}, [ tabs, router ]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const { tabsCut, tabsList, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabs, isMobile);
|
||||
const isSticky = useIsSticky(listRef, 5, stickyEnabled);
|
||||
@@ -68,6 +55,38 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled }: Props) =>
|
||||
);
|
||||
}, [ tabs, router ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady) {
|
||||
let tabIndex = 0;
|
||||
if (router.query.tab) {
|
||||
tabIndex = tabs.findIndex(({ id }) => id === router.query.tab);
|
||||
if (tabIndex < 0) {
|
||||
tabIndex = 0;
|
||||
}
|
||||
}
|
||||
setActiveTabIndex(tabIndex);
|
||||
}
|
||||
}, [ tabs, router, activeTabIndex ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabIndex < tabs.length && isMobile) {
|
||||
window.setTimeout(() => {
|
||||
const activeTabRef = tabsRefs[activeTabIndex];
|
||||
if (activeTabRef.current && listRef.current) {
|
||||
const activeTabRect = activeTabRef.current.getBoundingClientRect();
|
||||
|
||||
listRef.current.scrollTo({
|
||||
left: activeTabRect.left + listRef.current.scrollLeft - 16,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
// have to wait until DOM is updated and all styles to tabs is applied
|
||||
}, 300);
|
||||
}
|
||||
// run only when tab index or device type is updated
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ activeTabIndex, isMobile ]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
variant="soft-rounded"
|
||||
|
||||
@@ -51,19 +51,20 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
|
||||
}, [ tabs, disabled ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTabsRefs(disabled ? [] : tabsList.map((_, index) => tabsRefs[index] || React.createRef()));
|
||||
setTabsRefs(tabsList.map((_, index) => tabsRefs[index] || React.createRef()));
|
||||
setTabsCut(disabled ? tabs.length : 0);
|
||||
// update refs only when disabled prop changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ disabled ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tabsRefs.length > 0) {
|
||||
if (tabsRefs.length > 0 && !disabled) {
|
||||
setTabsCut(calculateCut());
|
||||
}
|
||||
}, [ calculateCut, tabsRefs ]);
|
||||
}, [ calculateCut, disabled, tabsRefs ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tabsRefs.length === 0) {
|
||||
if (tabsRefs.length === 0 || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
|
||||
return function cleanup() {
|
||||
resizeObserver.unobserve(document.body);
|
||||
};
|
||||
}, [ calculateCut, tabsRefs.length ]);
|
||||
}, [ calculateCut, disabled, tabsRefs.length ]);
|
||||
|
||||
return React.useMemo(() => {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user