Commit ffc74fa6 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #502 from blockscout/address-sockets

address txs socket
parents c61eeda9 9cb012bd
...@@ -8,7 +8,7 @@ import type { ApiResource, ResourceName } from './resources'; ...@@ -8,7 +8,7 @@ import type { ApiResource, ResourceName } from './resources';
export default function buildUrl( export default function buildUrl(
_resource: ApiResource | ResourceName, _resource: ApiResource | ResourceName,
pathParams?: Record<string, string | undefined>, pathParams?: Record<string, string | undefined>,
queryParams?: Record<string, string | number | undefined>, queryParams?: Record<string, string | Array<string> | number | undefined>,
) { ) {
// FIXME // FIXME
// 1. I was not able to figure out how to send CORS with credentials from localhost // 1. I was not able to figure out how to send CORS with credentials from localhost
......
...@@ -10,7 +10,7 @@ import type { ApiResource } from './resources'; ...@@ -10,7 +10,7 @@ import type { ApiResource } from './resources';
export interface Params { export interface Params {
pathParams?: Record<string, string | undefined>; pathParams?: Record<string, string | undefined>;
queryParams?: Record<string, string | number | undefined>; queryParams?: Record<string, string | Array<string> | number | undefined>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal'>; fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal'>;
} }
......
...@@ -12,7 +12,8 @@ function getSocketParams(router: NextRouter) { ...@@ -12,7 +12,8 @@ function getSocketParams(router: NextRouter) {
if ( if (
router.pathname === ROUTES.txs.pattern && router.pathname === ROUTES.txs.pattern &&
(router.query.tab === 'validated' || router.query.tab === undefined) && (router.query.tab === 'validated' || router.query.tab === undefined) &&
!router.query.block_number !router.query.block_number &&
!router.query.page
) { ) {
return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const }; return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const };
} }
...@@ -21,7 +22,12 @@ function getSocketParams(router: NextRouter) { ...@@ -21,7 +22,12 @@ function getSocketParams(router: NextRouter) {
return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const }; return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const };
} }
if (router.pathname === ROUTES.txs.pattern && router.query.tab === 'pending' && !router.query.block_number) { if (
router.pathname === ROUTES.txs.pattern &&
router.query.tab === 'pending' &&
!router.query.block_number &&
!router.query.page
) {
return { topic: 'transactions:new_pending_transaction' as const, event: 'pending_transaction' as const }; return { topic: 'transactions:new_pending_transaction' as const, event: 'pending_transaction' as const };
} }
......
...@@ -2,6 +2,8 @@ import type { Channel } from 'phoenix'; ...@@ -2,6 +2,8 @@ import type { Channel } from 'phoenix';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block'; import type { NewBlockSocketResponse } from 'types/api/block';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import type { Transaction } from 'types/api/transaction';
export type SocketMessageParams = SocketMessage.NewBlock | export type SocketMessageParams = SocketMessage.NewBlock |
SocketMessage.BlocksIndexStatus | SocketMessage.BlocksIndexStatus |
...@@ -13,6 +15,9 @@ SocketMessage.AddressBalance | ...@@ -13,6 +15,9 @@ SocketMessage.AddressBalance |
SocketMessage.AddressCurrentCoinBalance | SocketMessage.AddressCurrentCoinBalance |
SocketMessage.AddressTokenBalance | SocketMessage.AddressTokenBalance |
SocketMessage.AddressCoinBalance | SocketMessage.AddressCoinBalance |
SocketMessage.AddressTxs |
SocketMessage.AddressTxsPending |
SocketMessage.AddressTokenTransfer |
SocketMessage.Unknown; SocketMessage.Unknown;
interface SocketMessageParamsGeneric<Event extends string | undefined, Payload extends object | unknown> { interface SocketMessageParamsGeneric<Event extends string | undefined, Payload extends object | unknown> {
...@@ -34,5 +39,8 @@ export namespace SocketMessage { ...@@ -34,5 +39,8 @@ export namespace SocketMessage {
SocketMessageParamsGeneric<'current_coin_balance', { coin_balance: string; block_number: number; exchange_rate: string }>; SocketMessageParamsGeneric<'current_coin_balance', { coin_balance: string; block_number: number; exchange_rate: string }>;
export type AddressTokenBalance = SocketMessageParamsGeneric<'token_balance', { block_number: number }>; export type AddressTokenBalance = SocketMessageParamsGeneric<'token_balance', { block_number: number }>;
export type AddressCoinBalance = SocketMessageParamsGeneric<'coin_balance', { coin_balance: AddressCoinBalanceHistoryItem }>; export type AddressCoinBalance = SocketMessageParamsGeneric<'coin_balance', { coin_balance: AddressCoinBalanceHistoryItem }>;
export type AddressTxs = SocketMessageParamsGeneric<'transaction', { transaction: Transaction }>;
export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transaction: Transaction }>;
export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfer: TokenTransfer }>;
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>; export type Unknown = SocketMessageParamsGeneric<undefined, unknown>;
} }
...@@ -38,6 +38,8 @@ export const erc20: TokenTransfer = { ...@@ -38,6 +38,8 @@ export const erc20: TokenTransfer = {
tx_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193', tx_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
type: 'token_transfer', type: 'token_transfer',
timestamp: '2022-10-10T14:34:30.000000Z', timestamp: '2022-10-10T14:34:30.000000Z',
block_hash: '1',
log_index: '1',
}; };
export const erc721: TokenTransfer = { export const erc721: TokenTransfer = {
...@@ -77,6 +79,8 @@ export const erc721: TokenTransfer = { ...@@ -77,6 +79,8 @@ export const erc721: TokenTransfer = {
tx_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc', tx_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc',
type: 'token_transfer', type: 'token_transfer',
timestamp: '2022-10-10T14:34:30.000000Z', timestamp: '2022-10-10T14:34:30.000000Z',
block_hash: '1',
log_index: '1',
}; };
export const erc1155: TokenTransfer = { export const erc1155: TokenTransfer = {
...@@ -118,6 +122,8 @@ export const erc1155: TokenTransfer = { ...@@ -118,6 +122,8 @@ export const erc1155: TokenTransfer = {
tx_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746', tx_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_minting', type: 'token_minting',
timestamp: '2022-10-10T14:34:30.000000Z', timestamp: '2022-10-10T14:34:30.000000Z',
block_hash: '1',
log_index: '1',
}; };
export const erc1155multiple: TokenTransfer = { export const erc1155multiple: TokenTransfer = {
......
...@@ -37,6 +37,8 @@ interface TokenTransferBase { ...@@ -37,6 +37,8 @@ interface TokenTransferBase {
from: AddressParam; from: AddressParam;
to: AddressParam; to: AddressParam;
timestamp: string; timestamp: string;
block_hash: string;
log_index: string;
} }
export type TokenTransferPagination = { export type TokenTransferPagination = {
......
import { Hide, Show, Text } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import TokenTransfer from 'ui/shared/TokenTransfer/TokenTransfer'; import type { SocketMessage } from 'lib/socket/types';
import { AddressFromToFilterValues } from 'types/api/address';
import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import { getResourceKey } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import { TOKEN_TYPE, flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
type Filters = {
type: Array<TokenType>;
filter: AddressFromToFilter | undefined;
}
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const OVERLOAD_COUNT = 75;
const matchFilters = (filters: Filters, tokenTransfer: TokenTransfer, address?: string) => {
if (filters.filter) {
if (filters.filter === 'from' && tokenTransfer.from.hash !== address) {
return false;
}
if (filters.filter === 'to' && tokenTransfer.to.hash !== address) {
return false;
}
}
if (filters.type && filters.type.length) {
if (!filters.type.includes(tokenTransfer.token.type)) {
return false;
}
}
return true;
};
const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => { const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const currentAddress = router.query.id?.toString();
const [ socketAlert, setSocketAlert ] = React.useState('');
const [ newItemsCount, setNewItemsCount ] = React.useState(0);
const [ filters, setFilters ] = React.useState<Filters>(
{ type: getTokenFilterValue(router.query.type) || [], filter: getAddressFilterValue(router.query.filter) },
);
const { isError, isLoading, data, pagination, onFilterChange, isPaginationVisible } = useQueryWithPages({
resourceName: 'address_token_transfers',
pathParams: { id: currentAddress },
filters: filters,
scrollRef,
});
const handleTypeFilterChange = React.useCallback((nextValue: Array<TokenType>) => {
onFilterChange({ ...filters, type: nextValue });
setFilters((prevState) => ({ ...prevState, type: nextValue }));
}, [ filters, onFilterChange ]);
const handleAddressFilterChange = React.useCallback((nextValue: string) => {
const filterVal = getAddressFilterValue(nextValue);
onFilterChange({ ...filters, filter: filterVal });
setFilters((prevState) => ({ ...prevState, filter: filterVal }));
}, [ filters, onFilterChange ]);
const handleNewSocketMessage: SocketMessage.AddressTokenTransfer['handler'] = (payload) => {
setSocketAlert('');
if (data?.items && data.items.length >= OVERLOAD_COUNT) {
if (matchFilters(filters, payload.token_transfer, currentAddress)) {
setNewItemsCount(prev => prev + 1);
}
} else {
queryClient.setQueryData(
getResourceKey('address_token_transfers', { pathParams: { id: router.query.id?.toString() }, queryParams: { ...filters } }),
(prevData: AddressTokenTransferResponse | undefined) => {
if (!prevData) {
return;
}
if (!matchFilters(filters, payload.token_transfer, currentAddress)) {
return prevData;
}
return {
...prevData,
items: [
payload.token_transfer,
...prevData.items,
],
};
});
}
};
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please refresh the page to load new token transfers.');
}, []);
const handleSocketError = React.useCallback(() => {
setSocketAlert('An error has occurred while fetching new token transfers. Please refresh the page.');
}, []);
const channel = useSocketChannel({
topic: `addresses:${ (router.query.id as string).toLowerCase() }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: pagination.page !== 1,
});
useSocketMessage({
channel,
event: 'token_transfer',
handler: handleNewSocketMessage,
});
const numActiveFilters = (filters.type?.length || 0) + (filters.filter ? 1 : 0);
const isActionBarHidden = !numActiveFilters && !data?.items.length;
const content = (() => {
if (isLoading) {
return (
<>
<Hide below="lg">
<SkeletonTable columns={ [ '44px', '185px', '160px', '25%', '25%', '25%', '25%' ] }/>
</Hide>
<Show below="lg">
<SkeletonList/>
</Show>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
if (!data.items?.length && !numActiveFilters) {
return <Text as="span">There are no token transfers</Text>;
}
if (!data.items?.length) {
return <EmptySearchResult text={ `Couldn${ apos }t find any token transfer that matches your query.` }/>;
}
const items = data.items.reduce(flattenTotal, []);
return (
<>
<Hide below="lg">
<TokenTransferTable
data={ items }
baseAddress={ currentAddress }
showTxInfo
top={ 80 }
enableTimeIncrement
showSocketInfo={ pagination.page === 1 }
socketInfoAlert={ socketAlert }
socketInfoNum={ newItemsCount }
/>
</Hide>
<Show below="lg">
{ pagination.page === 1 && (
<SocketNewItemsNotice
url={ window.location.href }
num={ newItemsCount }
alert={ socketAlert }
type="token_transfer"
borderBottomRadius={ 0 }
/>
) }
<TokenTransferList
data={ items }
baseAddress={ currentAddress }
showTxInfo
enableTimeIncrement
/>
</Show>
</>
);
})();
const hash = router.query.id;
return ( return (
<TokenTransfer <>
resourceName="address_token_transfers" { !isActionBarHidden && (
pathParams={{ id: hash?.toString() }} <ActionBar mt={ -6 }>
baseAddress={ typeof hash === 'string' ? hash : undefined } <TokenTransferFilter
enableTimeIncrement defaultTypeFilters={ filters.type }
scrollRef={ scrollRef } onTypeFilterChange={ handleTypeFilterChange }
/> appliedFiltersNum={ numActiveFilters }
withAddressFilter
onAddressFilterChange={ handleAddressFilterChange }
defaultAddressFilter={ filters.filter }
/>
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
</ActionBar>
) }
{ content }
</>
); );
}; };
......
import { useQueryClient } from '@tanstack/react-query';
import castArray from 'lodash/castArray'; import castArray from 'lodash/castArray';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { AddressFromToFilter } from 'types/api/address'; import type { SocketMessage } from 'lib/socket/types';
import type { AddressFromToFilter, AddressTransactionsResponse } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address'; import { AddressFromToFilterValues } from 'types/api/address';
import { getResourceKey } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery'; import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages'; 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 ActionBar from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/Pagination'; import Pagination from 'ui/shared/Pagination';
import TxsContent from 'ui/txs/TxsContent'; import TxsContent from 'ui/txs/TxsContent';
import AddressTxsFilter from './AddressTxsFilter'; import AddressTxsFilter from './AddressTxsFilter';
const OVERLOAD_COUNT = 75;
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues); const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => { const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const [ socketAlert, setSocketAlert ] = React.useState('');
const [ newItemsCount, setNewItemsCount ] = React.useState(0);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -37,6 +48,85 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>} ...@@ -37,6 +48,85 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
addressTxsQuery.onFilterChange({ filter: newVal }); addressTxsQuery.onFilterChange({ filter: newVal });
}, [ addressTxsQuery ]); }, [ addressTxsQuery ]);
const handleNewSocketMessage: SocketMessage.AddressTxs['handler'] = (payload) => {
setSocketAlert('');
const currentAddress = router.query.id?.toString();
if (addressTxsQuery.data?.items && addressTxsQuery.data.items.length >= OVERLOAD_COUNT) {
if (
!filterValue ||
(filterValue === 'from' && payload.transaction.from.hash === currentAddress) ||
(filterValue === 'to' && payload.transaction.to?.hash === currentAddress)
) {
setNewItemsCount(prev => prev + 1);
}
}
queryClient.setQueryData(
getResourceKey('address_txs', { pathParams: { id: router.query.id?.toString() }, queryParams: { filter: filterValue } }),
(prevData: AddressTransactionsResponse | undefined) => {
if (!prevData) {
return;
}
const currIndex = prevData.items.findIndex((tx) => tx.hash === payload.transaction.hash);
if (currIndex > -1) {
prevData.items[currIndex] = payload.transaction;
return prevData;
}
if (prevData.items.length >= OVERLOAD_COUNT) {
return prevData;
}
if (filterValue) {
if (
(filterValue === 'from' && payload.transaction.from.hash !== currentAddress) ||
(filterValue === 'to' && payload.transaction.to?.hash !== currentAddress)
) {
return prevData;
}
}
return {
...prevData,
items: [
payload.transaction,
...prevData.items,
],
};
});
};
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please refresh the page to load new transactions.');
}, []);
const handleSocketError = React.useCallback(() => {
setSocketAlert('An error has occurred while fetching new transactions. Please refresh the page.');
}, []);
const channel = useSocketChannel({
topic: `addresses:${ (router.query.id as string).toLowerCase() }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: addressTxsQuery.pagination.page !== 1,
});
useSocketMessage({
channel,
event: 'transaction',
handler: handleNewSocketMessage,
});
useSocketMessage({
channel,
event: 'pending_transaction',
handler: handleNewSocketMessage,
});
const filter = ( const filter = (
<AddressTxsFilter <AddressTxsFilter
defaultFilter={ filterValue } defaultFilter={ filterValue }
...@@ -56,9 +146,11 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>} ...@@ -56,9 +146,11 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
<TxsContent <TxsContent
filter={ filter } filter={ filter }
query={ addressTxsQuery } query={ addressTxsQuery }
showSocketInfo={ false }
currentAddress={ typeof router.query.id === 'string' ? router.query.id : undefined } currentAddress={ typeof router.query.id === 'string' ? router.query.id : undefined }
enableTimeIncrement enableTimeIncrement
showSocketInfo={ addressTxsQuery.pagination.page === 1 }
socketInfoAlert={ socketAlert }
socketInfoNum={ newItemsCount }
/> />
</> </>
); );
......
...@@ -3,8 +3,9 @@ import React from 'react'; ...@@ -3,8 +3,9 @@ import React from 'react';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
import link from 'lib/link/link'; import link from 'lib/link/link';
import TxsNewItemNotice from 'ui/txs/TxsNewItemNotice'; import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import LatestTxsItem from './LatestTxsItem'; import LatestTxsItem from './LatestTxsItem';
import LatestTxsItemSkeleton from './LatestTxsItemSkeleton'; import LatestTxsItemSkeleton from './LatestTxsItemSkeleton';
...@@ -14,6 +15,8 @@ const LatestTransactions = () => { ...@@ -14,6 +15,8 @@ const LatestTransactions = () => {
const txsCount = isMobile ? 2 : 6; const txsCount = isMobile ? 2 : 6;
const { data, isLoading, isError } = useApiQuery('homepage_txs'); const { data, isLoading, isError } = useApiQuery('homepage_txs');
const { num, socketAlert } = useNewTxsSocket();
let content; let content;
if (isLoading) { if (isLoading) {
...@@ -33,7 +36,7 @@ const LatestTransactions = () => { ...@@ -33,7 +36,7 @@ const LatestTransactions = () => {
const txsUrl = link('txs'); const txsUrl = link('txs');
content = ( content = (
<> <>
<TxsNewItemNotice borderBottomRadius={ 0 } url={ link('txs') }/> <SocketNewItemsNotice borderBottomRadius={ 0 } url={ link('txs') } num={ num } alert={ socketAlert }/>
<Box mb={{ base: 3, lg: 4 }}> <Box mb={{ base: 3, lg: 4 }}>
{ data.slice(0, txsCount).map((tx => <LatestTxsItem key={ tx.hash } tx={ tx }/>)) } { data.slice(0, txsCount).map((tx => <LatestTxsItem key={ tx.hash } tx={ tx }/>)) }
</Box> </Box>
......
...@@ -6,6 +6,7 @@ import type { RoutedTab } from 'ui/shared/RoutedTabs/types'; ...@@ -6,6 +6,7 @@ import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
import useQueryWithPages from 'lib/hooks/useQueryWithPages'; import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import Page from 'ui/shared/Page/Page'; import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -29,9 +30,20 @@ const Transactions = () => { ...@@ -29,9 +30,20 @@ const Transactions = () => {
filters: { filter }, filters: { filter },
}); });
const { num, socketAlert } = useNewTxsSocket();
const isFirstPage = txsQuery.pagination.page === 1;
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
{ id: 'validated', title: verifiedTitle, component: <TxsContent query={ txsQuery }/> }, {
{ id: 'pending', title: 'Pending', component: <TxsContent query={ txsQuery } showBlockInfo={ false }/> }, id: 'validated',
title: verifiedTitle,
component: <TxsContent query={ txsQuery } showSocketInfo={ isFirstPage } socketInfoNum={ num } socketInfoAlert={ socketAlert }/> },
{
id: 'pending',
title: 'Pending',
component: <TxsContent query={ txsQuery } showBlockInfo={ false } showSocketInfo={ isFirstPage } socketInfoNum={ num } socketInfoAlert={ socketAlert }/>,
},
]; ];
return ( return (
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { ROUTES } from 'lib/link/routes';
import TestApp from 'playwright/TestApp';
import SocketNewItemsNotice from './SocketNewItemsNotice';
const hooksConfig = {
router: {
pathname: ROUTES.txs.pattern,
query: {},
},
};
test('2 new items in validated txs list +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<SocketNewItemsNotice url="/" num={ 2 }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('connection loss', async({ mount }) => {
const component = await mount(
<TestApp>
<SocketNewItemsNotice url="/" alert="Connection is lost. Please reload the page."/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('fetching', async({ mount }) => {
const component = await mount(
<TestApp>
<SocketNewItemsNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
...@@ -2,34 +2,36 @@ import { Alert, Link, Text, chakra, useTheme, useColorModeValue } from '@chakra- ...@@ -2,34 +2,36 @@ import { Alert, Link, Text, chakra, useTheme, useColorModeValue } from '@chakra-
import { transparentize } from '@chakra-ui/theme-tools'; import { transparentize } from '@chakra-ui/theme-tools';
import React from 'react'; import React from 'react';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
interface InjectedProps { interface InjectedProps {
content: React.ReactNode; content: React.ReactNode;
} }
interface Props { interface Props {
type?: 'transaction' | 'token_transfer';
children?: (props: InjectedProps) => JSX.Element; children?: (props: InjectedProps) => JSX.Element;
className?: string; className?: string;
url: string; url: string;
alert?: string;
num?: number;
} }
const TxsNewItemNotice = ({ children, className, url }: Props) => { const SocketNewItemsNotice = ({ children, className, url, num, alert, type = 'transaction' }: Props) => {
const { num, socketAlert } = useNewTxsSocket();
const theme = useTheme(); const theme = useTheme();
const alertContent = (() => { const alertContent = (() => {
if (socketAlert) { if (alert) {
return socketAlert; return alert;
} }
const name = type === 'token_transfer' ? 'token transfer' : 'transaction';
if (!num) { if (!num) {
return 'scanning new transactions...'; return `scanning new ${ name }s...`;
} }
return ( return (
<> <>
<Link href={ url }>{ num } more transaction{ num > 1 ? 's' : '' }</Link> <Link href={ url }>{ num } more { name }{ num > 1 ? 's' : '' }</Link>
<Text whiteSpace="pre"> ha{ num > 1 ? 've' : 's' } come in</Text> <Text whiteSpace="pre"> ha{ num > 1 ? 've' : 's' } come in</Text>
</> </>
); );
...@@ -53,4 +55,4 @@ const TxsNewItemNotice = ({ children, className, url }: Props) => { ...@@ -53,4 +55,4 @@ const TxsNewItemNotice = ({ children, className, url }: Props) => {
return children ? children({ content }) : content; return children ? children({ content }) : content;
}; };
export default chakra(TxsNewItemNotice); export default chakra(SocketNewItemsNotice);
import { Hide, Show, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo';
import type { PaginationFilters } from 'lib/api/resources';
import type { Params as UseApiQueryParams } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import { flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import { TOKEN_TYPE } from './helpers';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
interface Props<Resource extends 'tx_token_transfers' | 'address_token_transfers'> {
isLoading?: boolean;
isDisabled?: boolean;
resourceName: Resource;
baseAddress?: string;
showTxInfo?: boolean;
txHash?: string;
enableTimeIncrement?: boolean;
pathParams?: UseApiQueryParams<Resource>['pathParams'];
scrollRef?: React.RefObject<HTMLDivElement>;
}
type State = {
type: Array<TokenType> | undefined;
filter: AddressFromToFilter;
}
const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_transfers'>({
isLoading: isLoadingProp,
isDisabled,
resourceName,
baseAddress,
showTxInfo = true,
enableTimeIncrement,
pathParams,
scrollRef,
}: Props<Resource>) => {
const router = useRouter();
const [ filters, setFilters ] = React.useState<State>(
{ type: getTokenFilterValue(router.query.type), filter: getAddressFilterValue(router.query.filter) },
);
const { isError, isLoading, data, pagination, onFilterChange, isPaginationVisible } = useQueryWithPages({
resourceName,
pathParams,
options: { enabled: !isDisabled },
filters: filters as PaginationFilters<Resource>,
scrollRef,
});
const handleTypeFilterChange = React.useCallback((nextValue: Array<TokenType>) => {
onFilterChange({ ...filters, type: nextValue } as PaginationFilters<Resource>);
setFilters((prevState) => ({ ...prevState, type: nextValue }));
}, [ filters, onFilterChange ]);
const handleAddressFilterChange = React.useCallback((nextValue: string) => {
const filterVal = getAddressFilterValue(nextValue);
onFilterChange({ ...filters, filter: filterVal } as PaginationFilters<Resource>);
setFilters((prevState) => ({ ...prevState, filter: filterVal }));
}, [ filters, onFilterChange ]);
const numActiveFilters = (filters.type?.length || 0) + (filters.filter ? 1 : 0);
const isActionBarHidden = !numActiveFilters && !data?.items.length;
const content = (() => {
if (isLoading || isLoadingProp) {
return (
<>
<Hide below="lg">
<SkeletonTable columns={ showTxInfo ?
[ '44px', '185px', '160px', '25%', '25%', '25%', '25%' ] :
[ '185px', '25%', '25%', '25%', '25%' ] }
/>
</Hide>
<Show below="lg">
<SkeletonList/>
</Show>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
if (!data.items?.length && !numActiveFilters) {
return <Text as="span">There are no token transfers</Text>;
}
if (!data.items?.length) {
return <EmptySearchResult text={ `Couldn${ apos }t find any token transfer that matches your query.` }/>;
}
const items = data.items.reduce(flattenTotal, []);
return (
<>
<Hide below="lg">
<TokenTransferTable data={ items } baseAddress={ baseAddress } showTxInfo={ showTxInfo } top={ 80 } enableTimeIncrement={ enableTimeIncrement }/>
</Hide>
<Show below="lg">
<TokenTransferList data={ items } baseAddress={ baseAddress } showTxInfo={ showTxInfo } enableTimeIncrement={ enableTimeIncrement }/>
</Show>
</>
);
})();
return (
<>
{ !isActionBarHidden && (
<ActionBar mt={ -6 }>
<TokenTransferFilter
defaultTypeFilters={ filters.type }
onTypeFilterChange={ handleTypeFilterChange }
appliedFiltersNum={ numActiveFilters }
withAddressFilter={ Boolean(baseAddress) }
onAddressFilterChange={ handleAddressFilterChange }
defaultAddressFilter={ filters.filter }
/>
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
</ActionBar>
) }
{ content }
</>
);
};
export default React.memo(TokenTransfer);
import { Box } from '@chakra-ui/react';
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import TestApp from 'playwright/TestApp';
import { flattenTotal } from './helpers';
import TokenTransferList from './TokenTransferList';
const flattenData = tokenTransferMock.mixTokens.items.reduce(flattenTotal, []);
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('without tx info', async({ mount }) => {
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<TokenTransferList
data={ flattenData }
showTxInfo={ false }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with tx info', async({ mount }) => {
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<TokenTransferList
data={ flattenData }
showTxInfo={ true }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
...@@ -15,9 +15,9 @@ interface Props { ...@@ -15,9 +15,9 @@ interface Props {
const TokenTransferList = ({ data, baseAddress, showTxInfo, enableTimeIncrement }: Props) => { const TokenTransferList = ({ data, baseAddress, showTxInfo, enableTimeIncrement }: Props) => {
return ( return (
<Box> <Box>
{ data.map((item, index) => ( { data.map((item) => (
<TokenTransferListItem <TokenTransferListItem
key={ index } key={ item.tx_hash + item.block_hash + item.log_index }
{ ...item } { ...item }
baseAddress={ baseAddress } baseAddress={ baseAddress }
showTxInfo={ showTxInfo } showTxInfo={ showTxInfo }
......
...@@ -4,24 +4,19 @@ import React from 'react'; ...@@ -4,24 +4,19 @@ import React from 'react';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TokenTransfer from './TokenTransfer'; import { flattenTotal } from './helpers';
import TokenTransferTable from './TokenTransferTable';
const API_URL = buildApiUrl('tx_token_transfers', { id: '1' }); const flattenData = tokenTransferMock.mixTokens.items.reduce(flattenTotal, []);
test('without tx info +@mobile', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokenTransferMock.mixTokens),
}));
test('without tx info', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<Box h={{ base: '134px', lg: 6 }}/> <Box h={{ base: '134px', lg: 6 }}/>
<TokenTransfer <TokenTransferTable
resourceName="tx_token_transfers" data={ flattenData }
pathParams={{ id: '1' }} top={ 0 }
showTxInfo={ false } showTxInfo={ false }
/> />
</TestApp>, </TestApp>,
...@@ -30,18 +25,13 @@ test('without tx info +@mobile', async({ mount, page }) => { ...@@ -30,18 +25,13 @@ test('without tx info +@mobile', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with tx info +@mobile', async({ mount, page }) => { test('with tx info', async({ mount }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokenTransferMock.mixTokens),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<Box h={{ base: '134px', lg: 6 }}/> <Box h={{ base: '134px', lg: 6 }}/>
<TokenTransfer <TokenTransferTable
resourceName="tx_token_transfers" data={ flattenData }
pathParams={{ id: '1' }} top={ 0 }
showTxInfo={ true } showTxInfo={ true }
/> />
</TestApp>, </TestApp>,
......
import { Table, Tbody, Tr, Th } from '@chakra-ui/react'; import { Table, Tbody, Tr, Th, Td } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
import TokenTransferTableItem from 'ui/shared/TokenTransfer/TokenTransferTableItem'; import TokenTransferTableItem from 'ui/shared/TokenTransfer/TokenTransferTableItem';
...@@ -12,9 +13,21 @@ interface Props { ...@@ -12,9 +13,21 @@ interface Props {
showTxInfo?: boolean; showTxInfo?: boolean;
top: number; top: number;
enableTimeIncrement?: boolean; enableTimeIncrement?: boolean;
showSocketInfo?: boolean;
socketInfoAlert?: string;
socketInfoNum?: number;
} }
const TokenTransferTable = ({ data, baseAddress, showTxInfo, top, enableTimeIncrement }: Props) => { const TokenTransferTable = ({
data,
baseAddress,
showTxInfo,
top,
enableTimeIncrement,
showSocketInfo,
socketInfoAlert,
socketInfoNum,
}: Props) => {
return ( return (
<Table variant="simple" size="sm"> <Table variant="simple" size="sm">
...@@ -31,8 +44,28 @@ const TokenTransferTable = ({ data, baseAddress, showTxInfo, top, enableTimeIncr ...@@ -31,8 +44,28 @@ const TokenTransferTable = ({ data, baseAddress, showTxInfo, top, enableTimeIncr
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{ data.map((item, index) => ( { showSocketInfo && (
<TokenTransferTableItem key={ index } { ...item } baseAddress={ baseAddress } showTxInfo={ showTxInfo } enableTimeIncrement={ enableTimeIncrement }/> <Tr>
<Td colSpan={ 10 } p={ 0 }>
<SocketNewItemsNotice
borderRadius={ 0 }
pl="10px"
url={ window.location.href }
alert={ socketInfoAlert }
num={ socketInfoNum }
type="token_transfer"
/>
</Td>
</Tr>
) }
{ data.map((item) => (
<TokenTransferTableItem
key={ item.tx_hash + item.block_hash + item.log_index }
{ ...item }
baseAddress={ baseAddress }
showTxInfo={ showTxInfo }
enableTimeIncrement={ enableTimeIncrement }
/>
)) } )) }
</Tbody> </Tbody>
</Table> </Table>
......
import { Hide, Show, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import { SECOND } from 'lib/consts'; import { SECOND } from 'lib/consts';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TokenTransfer from 'ui/shared/TokenTransfer/TokenTransfer'; import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import { TOKEN_TYPE, flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import TxPendingAlert from 'ui/tx/TxPendingAlert'; import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo'; import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const TxTokenTransfer = () => { const TxTokenTransfer = () => {
const { isError, isLoading, data, socketStatus } = useFetchTxInfo({ updateDelay: 5 * SECOND }); const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const router = useRouter();
const [ typeFilter, setTypeFilter ] = React.useState<Array<TokenType>>(getTokenFilterValue(router.query.type) || []);
const tokenTransferQuery = useQueryWithPages({
resourceName: 'tx_token_transfers',
pathParams: { id: txsInfo.data?.hash.toString() },
options: { enabled: Boolean(txsInfo.data?.status && txsInfo.data?.hash) },
filters: { type: typeFilter },
});
if (!isLoading && !isError && !data.status) { const handleTypeFilterChange = React.useCallback((nextValue: Array<TokenType>) => {
return socketStatus ? <TxSocketAlert status={ socketStatus }/> : <TxPendingAlert/>; tokenTransferQuery.onFilterChange({ type: nextValue });
setTypeFilter(nextValue);
}, [ tokenTransferQuery ]);
if (!txsInfo.isLoading && !txsInfo.isError && !txsInfo.data.status) {
return txsInfo.socketStatus ? <TxSocketAlert status={ txsInfo.socketStatus }/> : <TxPendingAlert/>;
} }
if (isError) { if (txsInfo.isError || tokenTransferQuery.isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
const numActiveFilters = typeFilter.length;
const isActionBarHidden = !numActiveFilters && !tokenTransferQuery.data?.items.length;
const content = (() => {
if (txsInfo.isLoading || tokenTransferQuery.isLoading) {
return (
<>
<Hide below="lg" ssr={ false }>
<SkeletonTable columns={ [ '185px', '25%', '25%', '25%', '25%' ] }
/>
</Hide>
<Show below="lg" ssr={ false }>
<SkeletonList/>
</Show>
</>
);
}
if (!tokenTransferQuery.data.items?.length && !numActiveFilters) {
return <Text as="span">There are no token transfers</Text>;
}
if (!tokenTransferQuery.data.items?.length) {
return <EmptySearchResult text={ `Couldn${ apos }t find any token transfer that matches your query.` }/>;
}
const items = tokenTransferQuery.data.items.reduce(flattenTotal, []);
return (
<>
<Hide below="lg">
<TokenTransferTable data={ items } top={ 80 }/>
</Hide>
<Show below="lg">
<TokenTransferList data={ items }/>
</Show>
</>
);
})();
return ( return (
<TokenTransfer <>
isLoading={ isLoading } { !isActionBarHidden && (
isDisabled={ !data?.status || !data?.hash } <ActionBar mt={ -6 }>
resourceName="tx_token_transfers" <TokenTransferFilter
pathParams={{ id: data?.hash.toString() }} defaultTypeFilters={ typeFilter }
showTxInfo={ false } onTypeFilterChange={ handleTypeFilterChange }
txHash={ data?.hash || '' } appliedFiltersNum={ numActiveFilters }
/> />
{ tokenTransferQuery.isPaginationVisible && <Pagination ml="auto" { ...tokenTransferQuery.pagination }/> }
</ActionBar>
) }
{ content }
</>
); );
}; };
......
...@@ -9,10 +9,10 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -9,10 +9,10 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import type { Props as PaginationProps } from 'ui/shared/Pagination'; import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList'; import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable'; import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TxsHeaderMobile from './TxsHeaderMobile'; import TxsHeaderMobile from './TxsHeaderMobile';
import TxsListItem from './TxsListItem'; import TxsListItem from './TxsListItem';
import TxsNewItemNotice from './TxsNewItemNotice';
import TxsTable from './TxsTable'; import TxsTable from './TxsTable';
import useTxsSort from './useTxsSort'; import useTxsSort from './useTxsSort';
...@@ -25,12 +25,23 @@ type Props = { ...@@ -25,12 +25,23 @@ type Props = {
query: QueryResult; query: QueryResult;
showBlockInfo?: boolean; showBlockInfo?: boolean;
showSocketInfo?: boolean; showSocketInfo?: boolean;
socketInfoAlert?: string;
socketInfoNum?: number;
currentAddress?: string; currentAddress?: string;
filter?: React.ReactNode; filter?: React.ReactNode;
enableTimeIncrement?: boolean; enableTimeIncrement?: boolean;
} }
const TxsContent = ({ filter, query, showBlockInfo = true, showSocketInfo = true, currentAddress, enableTimeIncrement }: Props) => { const TxsContent = ({
filter,
query,
showBlockInfo = true,
showSocketInfo = true,
socketInfoAlert,
socketInfoNum,
currentAddress,
enableTimeIncrement,
}: Props) => {
const { data, isLoading, isError, setSortByField, setSortByValue, sorting } = useTxsSort(query); const { data, isLoading, isError, setSortByField, setSortByValue, sorting } = useTxsSort(query);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -64,9 +75,14 @@ const TxsContent = ({ filter, query, showBlockInfo = true, showSocketInfo = true ...@@ -64,9 +75,14 @@ const TxsContent = ({ filter, query, showBlockInfo = true, showSocketInfo = true
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
<Box> <Box>
{ showSocketInfo && ( { showSocketInfo && (
<TxsNewItemNotice url={ window.location.href }> <SocketNewItemsNotice
url={ window.location.href }
num={ socketInfoNum }
alert={ socketInfoAlert }
borderBottomRadius={ 0 }
>
{ ({ content }) => <Box>{ content }</Box> } { ({ content }) => <Box>{ content }</Box> }
</TxsNewItemNotice> </SocketNewItemsNotice>
) } ) }
{ txs.map(tx => ( { txs.map(tx => (
<TxsListItem <TxsListItem
...@@ -86,6 +102,8 @@ const TxsContent = ({ filter, query, showBlockInfo = true, showSocketInfo = true ...@@ -86,6 +102,8 @@ const TxsContent = ({ filter, query, showBlockInfo = true, showSocketInfo = true
sorting={ sorting } sorting={ sorting }
showBlockInfo={ showBlockInfo } showBlockInfo={ showBlockInfo }
showSocketInfo={ showSocketInfo } showSocketInfo={ showSocketInfo }
socketInfoAlert={ socketInfoAlert }
socketInfoNum={ socketInfoNum }
top={ query.isPaginationVisible ? 80 : 0 } top={ query.isPaginationVisible ? 80 : 0 }
currentAddress={ currentAddress } currentAddress={ currentAddress }
enableTimeIncrement={ enableTimeIncrement } enableTimeIncrement={ enableTimeIncrement }
......
...@@ -25,6 +25,7 @@ import Address from 'ui/shared/address/Address'; ...@@ -25,6 +25,7 @@ import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import InOutTag from 'ui/shared/InOutTag'; import InOutTag from 'ui/shared/InOutTag';
import ListItemMobile from 'ui/shared/ListItemMobile';
import TxStatus from 'ui/shared/TxStatus'; import TxStatus from 'ui/shared/TxStatus';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TxType from 'ui/txs/TxType'; import TxType from 'ui/txs/TxType';
...@@ -43,7 +44,6 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }: ...@@ -43,7 +44,6 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }:
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const iconColor = useColorModeValue('blue.600', 'blue.300'); const iconColor = useColorModeValue('blue.600', 'blue.300');
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const dataTo = tx.to ? tx.to : tx.created_contract; const dataTo = tx.to ? tx.to : tx.created_contract;
const isOut = Boolean(currentAddress && currentAddress === tx.from.hash); const isOut = Boolean(currentAddress && currentAddress === tx.from.hash);
...@@ -53,7 +53,7 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }: ...@@ -53,7 +53,7 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }:
return ( return (
<> <>
<Box width="100%" borderBottom="1px solid" borderColor={ borderColor } _first={{ borderTop: '1px solid', borderColor }}> <ListItemMobile display="block" width="100%" isAnimated key={ tx.hash }>
<Flex justifyContent="space-between" mt={ 4 }> <Flex justifyContent="space-between" mt={ 4 }>
<HStack> <HStack>
<TxType types={ tx.tx_types }/> <TxType types={ tx.tx_types }/>
...@@ -139,7 +139,7 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }: ...@@ -139,7 +139,7 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }:
<Text as="span">Fee { appConfig.network.currency.symbol } </Text> <Text as="span">Fee { appConfig.network.currency.symbol } </Text>
<Text as="span" variant="secondary">{ getValueWithUnit(tx.fee.value).toFormat() }</Text> <Text as="span" variant="secondary">{ getValueWithUnit(tx.fee.value).toFormat() }</Text>
</Box> </Box>
</Box> </ListItemMobile>
<Modal isOpen={ isOpen } onClose={ onClose } size="full"> <Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent paddingTop={ 4 }> <ModalContent paddingTop={ 4 }>
<ModalCloseButton/> <ModalCloseButton/>
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { ROUTES } from 'lib/link/routes';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import TxsNewItemNotice from './TxsNewItemNotice';
const hooksConfig = {
router: {
pathname: ROUTES.txs.pattern,
query: {},
},
};
export const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
test.describe.configure({ mode: 'serial' });
test('new item in validated txs list', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'transactions:new_transaction');
socketServer.sendMessage(socket, channel, 'transaction', { transaction: 1 });
await expect(component).toHaveScreenshot();
});
test.describe('dark mode', () => {
test.use({ colorScheme: 'dark' });
test('default view', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'transactions:new_transaction');
socketServer.sendMessage(socket, channel, 'transaction', { transaction: 1 });
await expect(component).toHaveScreenshot();
});
});
test('2 new items in validated txs list', async({ mount, page, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'transactions:new_transaction');
socketServer.sendMessage(socket, channel, 'transaction', { transaction: 1 });
socketServer.sendMessage(socket, channel, 'transaction', { transaction: 1 });
await page.waitForSelector('text=2 more');
await expect(component).toHaveScreenshot();
});
test('connection loss', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
await socketServer.joinChannel(socket, 'transactions:new_transaction');
socket.close();
await expect(component).toHaveScreenshot();
});
test('fetching', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
await socketServer.joinChannel(socket, 'transactions:new_transaction');
await expect(component).toHaveScreenshot();
});
import { Link, Table, Tbody, Tr, Th, Td, Icon } from '@chakra-ui/react'; import { Link, Table, Tbody, Tr, Th, Td, Icon } from '@chakra-ui/react';
import { AnimatePresence } from 'framer-motion';
import React from 'react'; import React from 'react';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
...@@ -6,9 +7,9 @@ import type { Sort } from 'types/client/txs-sort'; ...@@ -6,9 +7,9 @@ import type { Sort } from 'types/client/txs-sort';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import rightArrowIcon from 'icons/arrows/east.svg'; import rightArrowIcon from 'icons/arrows/east.svg';
import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TheadSticky from 'ui/shared/TheadSticky'; import TheadSticky from 'ui/shared/TheadSticky';
import TxsNewItemNotice from './TxsNewItemNotice';
import TxsTableItem from './TxsTableItem'; import TxsTableItem from './TxsTableItem';
type Props = { type Props = {
...@@ -18,11 +19,24 @@ type Props = { ...@@ -18,11 +19,24 @@ type Props = {
top: number; top: number;
showBlockInfo: boolean; showBlockInfo: boolean;
showSocketInfo: boolean; showSocketInfo: boolean;
socketInfoAlert?: string;
socketInfoNum?: number;
currentAddress?: string; currentAddress?: string;
enableTimeIncrement?: boolean; enableTimeIncrement?: boolean;
} }
const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, currentAddress, enableTimeIncrement }: Props) => { const TxsTable = ({
txs,
sort,
sorting,
top,
showBlockInfo,
showSocketInfo,
socketInfoAlert,
socketInfoNum,
currentAddress,
enableTimeIncrement,
}: Props) => {
return ( return (
<Table variant="simple" minWidth="950px" size="xs"> <Table variant="simple" minWidth="950px" size="xs">
<TheadSticky top={ top }> <TheadSticky top={ top }>
...@@ -53,19 +67,21 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, curr ...@@ -53,19 +67,21 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, curr
</TheadSticky> </TheadSticky>
<Tbody> <Tbody>
{ showSocketInfo && ( { showSocketInfo && (
<TxsNewItemNotice borderRadius={ 0 } url={ window.location.href }> <SocketNewItemsNotice borderRadius={ 0 } url={ window.location.href } alert={ socketInfoAlert } num={ socketInfoNum }>
{ ({ content }) => <Tr><Td colSpan={ 10 } p={ 0 }>{ content }</Td></Tr> } { ({ content }) => <Tr><Td colSpan={ 10 } p={ 0 }>{ content }</Td></Tr> }
</TxsNewItemNotice> </SocketNewItemsNotice>
) } ) }
{ txs.map((item) => ( <AnimatePresence initial={ false }>
<TxsTableItem { txs.map((item) => (
key={ item.hash } <TxsTableItem
tx={ item } key={ item.hash }
showBlockInfo={ showBlockInfo } tx={ item }
currentAddress={ currentAddress } showBlockInfo={ showBlockInfo }
enableTimeIncrement={ enableTimeIncrement } currentAddress={ currentAddress }
/> enableTimeIncrement={ enableTimeIncrement }
)) } />
)) }
</AnimatePresence>
</Tbody> </Tbody>
</Table> </Table>
); );
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
Show, Show,
Hide, Hide,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import React from 'react'; import React from 'react';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
...@@ -65,7 +66,14 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement } ...@@ -65,7 +66,14 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }
const infoBorderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200'); const infoBorderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return ( return (
<Tr> <Tr
as={ motion.tr }
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transitionDuration="normal"
transitionTimingFunction="linear"
key={ tx.hash }
>
<Td pl={ 4 }> <Td pl={ 4 }>
<Popover placement="right-start" openDelay={ 300 } isLazy> <Popover placement="right-start" openDelay={ 300 } isLazy>
{ ({ isOpen }) => ( { ({ isOpen }) => (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment