Commit ae748ffe authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into chakra-theme-tests

parents b1feac37 734546a2
name: Run E2E tests k8s
on:
# push:
pull_request:
pull_request_review:
types: [submitted]
workflow_dispatch:
env:
......@@ -14,59 +14,13 @@ env:
BASTION_SSH_KEY: ${{secrets.BASTION_SSH_KEY}}
jobs:
push_to_registry:
name: Push Docker image to registry
runs-on: ubuntu-latest
outputs:
shortSha: ${{ steps.output-step.outputs.short-sha }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# Will automatically make nice tags, see the table here https://github.com/docker/metadata-action#basic
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: ghcr.io/blockscout/frontend
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Add SHORT_SHA env property with commit short sha
run: echo "SHORT_SHA=`echo ${GITHUB_SHA} | cut -c1-8`" >> $GITHUB_ENV
- name: Add outputs
run: |
echo "::set-output name=short-sha::${{ env.SHORT_SHA }}"
id: output-step
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ghcr.io/blockscout/frontend:prerelease-${{ env.SHORT_SHA }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT_SHA=${{ env.SHORT_SHA }}
deploy_and_tests:
needs: push_to_registry
if: github.event.review.state == 'approved'
# needs: push_to_registry
uses: blockscout/blockscout-ci-cd/.github/workflows/e2e_new.yaml@master
with:
appNamespace: e2e-front-$GITHUB_SHA_SHORT
frontendImage: ghcr.io/blockscout/frontend:prerelease-${{ needs.push_to_registry.outputs.shortSha }}
frontendImage: ghcr.io/blockscout/frontend:prerelease-$GITHUB_REF_NAME_SLUG
blockscoutIngressHost: e2e-blockscout-$GITHUB_SHA_SHORT
frontendIngressHost: e2e-blockscout-$GITHUB_SHA_SHORT
gethIngressHost: e2e-geth-$GITHUB_SHA_SHORT
......
......@@ -58,7 +58,6 @@ jobs:
file: ./Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ghcr.io/blockscout/frontend:prerelease-${{ env.GITHUB_REF_NAME_SLUG }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
......@@ -69,12 +68,12 @@ jobs:
needs: push_to_registry
uses: blockscout/blockscout-ci-cd/.github/workflows/deploy.yaml@master
with:
env_vars: VALUES_DIR=deploy/values/review,APP_NAME=bs-stack,DOCKER_IMAGE=prerelease-$GITHUB_REF_NAME_SLUG
env_vars: VALUES_DIR=deploy/values/review,APP_NAME=bs-stack,DOCKER_IMAGE=prerelease-$GITHUB_REF_NAME_SLUG,K8S_DOMAIN=blockscout-main.test.aws-k8s.blockscout.com
globalEnv: review
appNamespace: review-front-$GITHUB_REF_NAME_SLUG
blockscoutIngressHost: blockscout
frontendIngressHost: blockscout
blockscoutIngressHost: review-front
frontendIngressHost: review-front
frontendImage: ghcr.io/blockscout/frontend:prerelease-$GITHUB_REF_NAME_SLUG
gethIngressHost: geth
scVerifierIngressHost: sc-verifier
gethIngressHost: review-front-geth
scVerifierIngressHost: review-front-sc-verifier
secrets: inherit
......@@ -11,6 +11,8 @@ NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true
NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true
NEXT_PUBLIC_NETWORK_LOGO=
NEXT_PUBLIC_NETWORK_SMALL_LOGO=
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
......
......@@ -22,8 +22,12 @@ blockscout:
# enable ingress
ingress:
enabled: true
annotations: {}
annotations:
# - 'nginx.ingress.kubernetes.io/rewrite-target: /$2'
- 'nginx.ingress.kubernetes.io/cors-allow-origin: "https://*.blockscout-main.test.aws-k8s.blockscout.com, https://*.test.aws-k8s.blockscout.com, http://localhost:3000"'
- 'nginx.ingress.kubernetes.io/cors-allow-credentials: "true"'
- 'nginx.ingress.kubernetes.io/cors-allow-methods: PUT, GET, POST, OPTIONS, DELETE, PATCH'
- 'nginx.ingress.kubernetes.io/enable-cors: "true"'
host:
_default: blockscout.test.blockscout.aws-k8s.blockscout.com
# enable https
......@@ -158,7 +162,7 @@ postgres:
# strategy: Recreate
persistence: true
storage: 500Gi
storage: 1000Gi
resources:
limits:
......
......@@ -394,4 +394,4 @@ frontend:
NEXT_PUBLIC_APP_HOST:
_default: blockscout.com
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: [{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]"
import type { Types } from 'typescript-cookie';
import { Cookies } from 'typescript-cookie';
import Cookies from 'js-cookie';
import isBrowser from './isBrowser';
......@@ -14,10 +13,13 @@ export function get(name?: NAMES | undefined | null, serverCookie?: string) {
if (!isBrowser()) {
return serverCookie ? getFromCookieString(serverCookie, name) : undefined;
}
return Cookies.get(name);
if (name) {
return Cookies.get(name);
}
}
export function set(name: string, value: string, attributes: Types.CookieAttributes = {}) {
export function set(name: string, value: string, attributes: Cookies.CookieAttributes = {}) {
attributes.path = '/';
return Cookies.set(name, value, attributes);
......
export default function getFilterValue<FilterType>(filterValues: ReadonlyArray<FilterType>, val: string | Array<string> | undefined) {
if (typeof val === 'string' && filterValues.includes(val as unknown as FilterType)) {
return val as unknown as FilterType;
}
}
export default function getFilterValue<FilterType>(filterValues: ReadonlyArray<FilterType>, val: string | Array<string> | undefined) {
const valArray = [];
if (typeof val === 'string') {
valArray.push(...val.split(','));
}
if (Array.isArray(val)) {
val.forEach(el => valArray.push(...el.split(',')));
}
return valArray.filter(el => filterValues.includes(el as unknown as FilterType)) as unknown as Array<FilterType>;
}
......@@ -29,7 +29,7 @@ export default function useNavItems() {
{ text: 'Tokens', url: link('tokens'), icon: tokensIcon, isActive: currentRoute === 'tokens', isNewUi: false },
isMarketplaceFilled ?
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps', isNewUi: true } : null,
{ text: 'Charts & stats', url: link('stats'), icon: statsIcon, isActive: currentRoute === 'stats', isNewUi: true },
{ text: 'Charts & stats', url: link('stats'), icon: statsIcon, isActive: currentRoute === 'stats', isNewUi: false },
// there should be custom site sections like Stats, Faucet, More, etc but never an 'other'
// examples https://explorer-edgenet.polygon.technology/ and https://explorer.celo.org/
// at this stage custom menu items is under development, we will implement it later
......
......@@ -85,7 +85,7 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const onPrevPageClick = useCallback(() => {
// returning to the first page
// we dont have pagination params for the first page
let nextPageQuery: typeof router.query = {};
let nextPageQuery: typeof router.query = { ...router.query };
if (page === 2) {
nextPageQuery = omit(router.query, paginationFields, 'page');
canGoBackwards.current = true;
......@@ -99,17 +99,24 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
.then(() => {
scrollToTop();
setPage(prev => prev - 1);
page === 2 && queryClient.clear();
page === 2 && queryClient.removeQueries({ queryKey: [ queryName ] });
});
}, [ router, page, paginationFields, pageParams, queryClient, scrollToTop ]);
}, [ router, page, paginationFields, pageParams, queryClient, scrollToTop, queryName ]);
const resetPage = useCallback(() => {
queryClient.removeQueries({ queryKey: [ queryName ] });
router.push({ pathname: router.pathname, query: omit(router.query, paginationFields, 'page') }, undefined, { shallow: true }).then(() => {
queryClient.removeQueries({ queryKey: [ queryName ] });
scrollToTop();
setPage(1);
setPageParams([ ]);
canGoBackwards.current = true;
window.setTimeout(() => {
// FIXME after router is updated we still have inactive queries for previously visited page (e.g third), where we came from
// so have to remove it but with some delay :)
queryClient.removeQueries({ queryKey: [ queryName ], type: 'inactive' });
}, 100);
});
}, [ queryClient, queryName, router, paginationFields, scrollToTop ]);
......@@ -117,7 +124,7 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const newQuery = omit(router.query, PAGINATION_FIELDS[queryName], 'page', PAGINATION_FILTERS_FIELDS[queryName]);
if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => {
if (value) {
if (value && value.length) {
newQuery[key] = Array.isArray(value) ? value.join(',') : (value || '');
}
});
......
import type { Channel } from 'phoenix';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block';
export type SocketMessageParams = SocketMessage.NewBlock |
......@@ -19,16 +20,6 @@ interface SocketMessageParamsGeneric<Event extends string | undefined, Payload e
handler: (payload: Payload) => void;
}
export interface AddressCoinBalancePayload {
coin_balance: {
block_number: number;
block_timestamp?: string;
delta?: string;
transaction_hash?: string | null;
value?: string;
};
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace SocketMessage {
export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>;
......@@ -40,6 +31,6 @@ export namespace SocketMessage {
export type AddressCurrentCoinBalance =
SocketMessageParamsGeneric<'current_coin_balance', { coin_balance: string; block_number: number; exchange_rate: string }>;
export type AddressTokenBalance = SocketMessageParamsGeneric<'token_balance', { block_number: number }>;
export type AddressCoinBalance = SocketMessageParamsGeneric<'coin_balance', AddressCoinBalancePayload>;
export type AddressCoinBalance = SocketMessageParamsGeneric<'coin_balance', { coin_balance: AddressCoinBalanceHistoryItem }>;
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>;
}
export const base = {
block_number: 30367643,
block_timestamp: '2022-12-11T17:55:20Z',
delta: '-5568096000000000',
transaction_hash: null,
value: '107014805905725000000',
};
import type { FeaturedNetwork } from 'types/networks';
const FEATURED_NETWORKS: Array<FeaturedNetwork> = [
{ title: 'Gnosis Chain', url: 'https://blockscout.com/xdai/mainnet', group: 'mainnets', type: 'xdai_mainnet' },
{ title: 'Optimism on Gnosis Chain', url: 'https://blockscout.com/xdai/optimism', group: 'mainnets', type: 'xdai_optimism' },
{ title: 'Arbitrum on xDai', url: 'https://blockscout.com/xdai/aox', group: 'mainnets' },
{ title: 'Ethereum', url: 'https://blockscout.com/eth/mainnet', group: 'mainnets', type: 'eth_mainnet' },
{ title: 'Ethereum Classic', url: 'https://blockscout.com/etx/mainnet', group: 'mainnets', type: 'etc_mainnet', icon: 'https://example.com/my-logo.png' },
{ title: 'POA', url: 'https://blockscout.com/poa/core', group: 'mainnets', type: 'poa_core' },
{ title: 'RSK', url: 'https://blockscout.com/rsk/mainnet', group: 'mainnets', type: 'rsk_mainnet' },
{ title: 'Gnosis Chain Testnet', url: 'https://blockscout.com/xdai/testnet', group: 'testnets', type: 'xdai_testnet' },
{ title: 'POA Sokol', url: 'https://blockscout.com/poa/sokol', group: 'testnets', type: 'poa_sokol' },
{ title: 'ARTIS Σ1', url: 'https://blockscout.com/artis/sigma1', group: 'other', type: 'artis_sigma1' },
{ title: 'LUKSO L14', url: 'https://blockscout.com/lukso/l14', group: 'other', type: 'lukso_l14' },
{ title: 'Astar', url: 'https://blockscout.com/astar', group: 'other', type: 'astar' },
];
export const FEATURED_NETWORKS_MOCK = JSON.stringify(FEATURED_NETWORKS).replaceAll('"', '\'');
export const base = {
avatar: 'https://avatars.githubusercontent.com/u/22130104',
email: 'tom@ohhhh.me',
name: 'tom goriunov',
nickname: 'tom2drum',
};
......@@ -24,7 +24,7 @@ const watchlistWithTokensHandler = async(_req: NextApiRequest, res: NextApiRespo
}
const data = await Promise.all(watchlistData.map(async item => {
const tokens = await fetch(`?module=account&action=tokenlist&address=${ item.address_hash }`);
const tokens = await fetch(`/api/?module=account&action=tokenlist&address=${ item.address_hash }`);
const tokensData = await tokens.json() as Tokenlist;
return ({ ...item, tokens_count: Array.isArray(tokensData.result) ? tokensData.result.length : 0 });
......
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;
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/v2/addresses/${ req.query.id }/coin-balance-history-by-day`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
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 }/coin-balance-history${ searchParamsStr ? '?' + searchParamsStr : '' }`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
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 }/internal-transactions${ searchParamsStr ? '?' + searchParamsStr : '' }`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
......@@ -96,6 +96,15 @@ const config: PlaywrightTestConfig = {
colorScheme: 'dark',
},
},
{
name: 'dark color mode desktop xl',
grep: /\+@dark-mode-xl/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1600, height: 1000 },
colorScheme: 'dark',
},
},
],
};
......
......@@ -2,6 +2,8 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { AppContextProvider } from 'lib/appContext';
import type { Props as PageProps } from 'lib/next/getServerSideProps';
import { SocketProvider } from 'lib/socket/context';
import { PORT } from 'playwright/fixtures/socketServer';
import theme from 'theme';
......@@ -9,9 +11,19 @@ import theme from 'theme';
type Props = {
children: React.ReactNode;
withSocket?: boolean;
appContext?: {
pageProps: PageProps;
};
}
const TestApp = ({ children, withSocket }: Props) => {
const defaultAppContext = {
pageProps: {
cookies: '',
referrer: '',
},
};
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
......@@ -25,7 +37,9 @@ const TestApp = ({ children, withSocket }: Props) => {
<ChakraProvider theme={ theme }>
<QueryClientProvider client={ queryClient }>
<SocketProvider url={ withSocket ? `ws://localhost:${ PORT }` : undefined }>
{ children }
<AppContextProvider { ...appContext }>
{ children }
</AppContextProvider>
</SocketProvider>
</QueryClientProvider>
</ChakraProvider>
......
import type { BrowserContext } from '@playwright/test';
import * as cookies from 'lib/cookies';
export default function authFixture(context: BrowserContext) {
context.addCookies([ { name: cookies.NAMES.API_TOKEN, value: 'foo', domain: 'localhost', path: '/' } ]);
}
......@@ -2,7 +2,7 @@ import type { TestFixture, Page } from '@playwright/test';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import type { AddressCoinBalancePayload } from 'lib/socket/types';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block';
type ReturnType = () => Promise<WebSocket>;
......@@ -54,7 +54,7 @@ export const joinChannel = async(socket: WebSocket, channelName: string) => {
});
};
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'coin_balance', payload: AddressCoinBalancePayload): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'coin_balance', payload: { coin_balance: AddressCoinBalanceHistoryItem }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_balance', payload: { block_number: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'transaction', payload: { transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
......
......@@ -25,10 +25,8 @@ do
configValue="$(cut -d'=' -f2- <<<"$line")";
# if there is a value, escape it and add line to target file
if [ -n "$configValue" ]; then
escapedConfigValue=$(echo $configValue | sed s/\'/\"/g);
echo "window.process.env.${configName} = localStorage.getItem('${configName}') || '${escapedConfigValue}';" >> $targetFile;
fi
escapedConfigValue=$(echo $configValue | sed s/\'/\"/g);
echo "window.process.env.${configName} = localStorage.getItem('${configName}') || '${escapedConfigValue}';" >> $targetFile;
done < $envFile
done
......
import type { Transaction } from 'types/api/transaction';
import type { AddressTag, WatchlistName } from './addressParams';
import type { Block } from './block';
import type { InternalTransaction } from './internalTransaction';
import type { TokenInfo, TokenType } from './tokenInfo';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
......@@ -44,7 +46,9 @@ export interface AddressTransactionsResponse {
} | null;
}
type AddressFromToFilter = 'from' | 'to' | undefined;
export const AddressFromToFilterValues = [ 'from', 'to' ] as const;
export type AddressFromToFilter = typeof AddressFromToFilterValues[number] | undefined;
export type AddressTxsFilters = {
filter: AddressFromToFilter;
......@@ -57,5 +61,38 @@ export interface AddressTokenTransferResponse {
export type AddressTokenTransferFilters = {
filter: AddressFromToFilter;
type: TokenType;
type: Array<TokenType>;
}
export interface AddressCoinBalanceHistoryItem {
block_number: number;
block_timestamp: string;
delta: string;
transaction_hash: string | null;
value: string;
}
export interface AddressCoinBalanceHistoryResponse {
items: Array<AddressCoinBalanceHistoryItem>;
next_page_params: {
block_number: number;
items_count: number;
};
}
export interface AddressBlocksValidatedResponse {
items: Array<Block>;
next_page_params: {
block_number: number;
items_count: number;
};
}
export interface AddressInternalTxsResponse {
items: Array<InternalTransaction>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
transaction_index: number;
} | null;
}
import type { AddressTransactionsResponse, AddressTokenTransferResponse, AddressTxsFilters, AddressTokenTransferFilters } from 'types/api/address';
import type {
AddressTransactionsResponse,
AddressTokenTransferResponse,
AddressTxsFilters,
AddressTokenTransferFilters,
AddressCoinBalanceHistoryResponse,
AddressBlocksValidatedResponse,
AddressInternalTxsResponse,
} from 'types/api/address';
import type { BlocksResponse, BlockTransactionsResponse, BlockFilters } from 'types/api/block';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { LogsResponse } from 'types/api/log';
......@@ -11,6 +19,7 @@ import type { KeysOfObjectOrNull } from 'types/utils/KeysOfObjectOrNull';
export type PaginatedQueryKeys =
QueryKeys.addressTxs |
QueryKeys.addressTokenTransfers |
QueryKeys.addressInternalTxs |
QueryKeys.blocks |
QueryKeys.blocksReorgs |
QueryKeys.blocksUncles |
......@@ -19,9 +28,12 @@ export type PaginatedQueryKeys =
QueryKeys.txsPending |
QueryKeys.txInternals |
QueryKeys.txLogs |
QueryKeys.txTokenTransfers;
QueryKeys.txTokenTransfers |
QueryKeys.addressCoinBalanceHistory |
QueryKeys.addressBlocksValidated;
export type PaginatedResponse<Q extends PaginatedQueryKeys> =
Q extends QueryKeys.addressInternalTxs ? AddressInternalTxsResponse :
Q extends QueryKeys.addressTxs ? AddressTransactionsResponse :
Q extends QueryKeys.addressTokenTransfers ? AddressTokenTransferResponse :
Q extends (QueryKeys.blocks | QueryKeys.blocksReorgs | QueryKeys.blocksUncles) ? BlocksResponse :
......@@ -31,10 +43,12 @@ export type PaginatedResponse<Q extends PaginatedQueryKeys> =
Q extends QueryKeys.txInternals ? InternalTransactionsResponse :
Q extends QueryKeys.txLogs ? LogsResponse :
Q extends QueryKeys.txTokenTransfers ? TokenTransferResponse :
never
Q extends QueryKeys.addressCoinBalanceHistory ? AddressCoinBalanceHistoryResponse :
Q extends QueryKeys.addressBlocksValidated ? AddressBlocksValidatedResponse :
never
export type PaginationFilters<Q extends PaginatedQueryKeys> =
Q extends QueryKeys.addressTxs ? AddressTxsFilters :
Q extends (QueryKeys.addressTxs | QueryKeys.addressInternalTxs) ? AddressTxsFilters :
Q extends QueryKeys.addressTokenTransfers ? AddressTokenTransferFilters :
Q extends QueryKeys.blocks ? BlockFilters :
Q extends QueryKeys.txsValidate ? TTxsFilters :
......@@ -50,6 +64,7 @@ type PaginationFields = {
export const PAGINATION_FIELDS: PaginationFields = {
[QueryKeys.addressTxs]: [ 'block_number', 'items_count', 'index' ],
[QueryKeys.addressInternalTxs]: [ 'block_number', 'items_count', 'index', 'transaction_index' ],
[QueryKeys.addressTokenTransfers]: [ 'block_number', 'items_count', 'index', 'transaction_hash' ],
[QueryKeys.blocks]: [ 'block_number', 'items_count' ],
[QueryKeys.blocksReorgs]: [ 'block_number', 'items_count' ],
......@@ -60,6 +75,8 @@ export const PAGINATION_FIELDS: PaginationFields = {
[QueryKeys.txInternals]: [ 'block_number', 'items_count', 'transaction_hash', 'index', 'transaction_index' ],
[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 = {
......@@ -68,7 +85,10 @@ type PaginationFiltersFields = {
export const PAGINATION_FILTERS_FIELDS: PaginationFiltersFields = {
[QueryKeys.addressTxs]: [ 'filter' ],
[QueryKeys.addressInternalTxs]: [ 'filter' ],
[QueryKeys.addressTokenTransfers]: [ 'filter', 'type' ],
[QueryKeys.addressCoinBalanceHistory]: [],
[QueryKeys.addressBlocksValidated]: [],
[QueryKeys.blocks]: [ 'type' ],
[QueryKeys.txsValidate]: [ 'filter', 'type', 'method' ],
[QueryKeys.txsPending]: [ 'filter', 'type', 'method' ],
......
......@@ -20,12 +20,20 @@ export type GasPrices = {
}
export type Stats = {
totalBlocksAllTime: string;
counters: {
averageBlockTime: string;
completedTransactions: string;
totalAccounts: string;
totalBlocksAllTime: string;
totalTransactions: string;
};
}
export type Charts = {
'chart': Array<{
date: string;
value: string;
}>;
chart: Array<ChartsItem>;
}
export type ChartsItem ={
date: string;
value: string;
}
......@@ -24,6 +24,10 @@ export enum QueryKeys {
address='address',
addressCounters='address-counters',
addressTokenBalances='address-token-balances',
addressCoinBalanceHistory='address-coin-balance-history',
addressCoinBalanceHistoryByDay='address-coin-balance-history-by-day',
addressTxs='addressTxs',
addressTokenTransfers='addressTokenTransfers',
addressBlocksValidated='address-blocks-validated',
addressInternalTxs='address-internal-txs',
}
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);
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, AddressCoinBalanceHistoryResponse } from 'types/api/address';
import { QueryKeys } from 'types/client/queries';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import SocketAlert from 'ui/shared/SocketAlert';
import AddressCoinBalanceChart from './coinBalance/AddressCoinBalanceChart';
import AddressCoinBalanceHistory from './coinBalance/AddressCoinBalanceHistory';
interface Props {
addressQuery: UseQueryResult<Address>;
}
const AddressCoinBalance = ({ addressQuery }: Props) => {
const [ socketAlert, setSocketAlert ] = React.useState(false);
const queryClient = useQueryClient();
const coinBalanceQuery = useQueryWithPages({
apiPath: `/node-api/addresses/${ addressQuery.data?.hash }/coin-balance-history`,
queryName: QueryKeys.addressCoinBalanceHistory,
options: {
enabled: Boolean(addressQuery.data),
},
});
const handleSocketError = React.useCallback(() => {
setSocketAlert(true);
}, []);
const handleNewSocketMessage: SocketMessage.AddressCoinBalance['handler'] = React.useCallback((payload) => {
setSocketAlert(false);
queryClient.setQueryData(
[ QueryKeys.addressCoinBalanceHistory, { page: coinBalanceQuery.pagination.page } ],
(prevData: AddressCoinBalanceHistoryResponse | undefined) => {
if (!prevData) {
return;
}
return {
...prevData,
items: [
payload.coin_balance,
...prevData.items,
],
};
});
}, [ coinBalanceQuery.pagination.page, queryClient ]);
const channel = useSocketChannel({
topic: `addresses:${ addressQuery.data?.hash.toLowerCase() }`,
onSocketClose: handleSocketError,
onSocketError: handleSocketError,
isDisabled: addressQuery.isLoading || addressQuery.isError || !addressQuery.data.hash || coinBalanceQuery.pagination.page !== 1,
});
useSocketMessage({
channel,
event: 'coin_balance',
handler: handleNewSocketMessage,
});
return (
<>
{ socketAlert && <SocketAlert mb={ 6 }/> }
<AddressCoinBalanceChart/>
<AddressCoinBalanceHistory query={ coinBalanceQuery }/>
</>
);
};
export default React.memo(AddressCoinBalance);
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as internalTxsMock from 'mocks/txs/internalTxs';
import TestApp from 'playwright/TestApp';
import AddressInternalTxs from './AddressInternalTxs';
const ADDRESS_HASH = internalTxsMock.base.from.hash;
const API_URL_TX_INTERNALS = `/node-api/addresses/${ ADDRESS_HASH }/internal-transactions`;
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH },
},
};
test('base view +@mobile', async({ mount, page }) => {
await page.route(API_URL_TX_INTERNALS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(internalTxsMock.baseResponse),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressInternalTxs/>
</TestApp>,
{ hooksConfig },
);
await page.waitForResponse(API_URL_TX_INTERNALS),
await expect(component).toHaveScreenshot();
});
import { Text, Show, Hide } from '@chakra-ui/react';
import castArray from 'lodash/castArray';
import { useRouter } from 'next/router';
import React from 'react';
import { Element } from 'react-scroll';
import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import { QueryKeys } from 'types/client/queries';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import AddressIntTxsSkeletonDesktop from 'ui/address/internals/AddressIntTxsSkeletonDesktop';
import AddressIntTxsSkeletonMobile from 'ui/address/internals/AddressIntTxsSkeletonMobile';
import AddressIntTxsTable from 'ui/address/internals/AddressIntTxsTable';
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 AddressTxsFilter from './AddressTxsFilter';
import AddressIntTxsList from './internals/AddressIntTxsList';
const SCROLL_ELEM = 'address-internas-txs';
const SCROLL_OFFSET = -100;
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const AddressInternalTxs = () => {
const router = useRouter();
const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(getFilterValue(router.query.filter));
const queryId = router.query.id;
const queryIdArray = castArray(queryId);
const queryIdStr = queryIdArray[0];
const { data, isLoading, isError, pagination, onFilterChange } = useQueryWithPages({
apiPath: `/node-api/addresses/${ queryId }/internal-transactions`,
queryName: QueryKeys.addressInternalTxs,
queryIds: queryIdArray,
filters: { filter: filterValue },
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET },
});
const isPaginatorHidden = !isLoading && !isError && pagination.page === 1 && !pagination.hasNextPage;
const handleFilterChange = React.useCallback((val: string | Array<string>) => {
const newVal = getFilterValue(val);
setFilterValue(newVal);
onFilterChange({ filter: newVal });
}, [ onFilterChange ]);
if (isLoading) {
return (
<>
<Show below="lg"><AddressIntTxsSkeletonMobile/></Show>
<Hide below="lg"><AddressIntTxsSkeletonDesktop/></Hide>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
if (data.items.length === 0 && !filterValue) {
return <Text as="span">There are no internal transactions for this address.</Text>;
}
let content;
if (data.items.length === 0) {
content = <EmptySearchResult text={ `Couldn${ apos }t find any transaction that matches your query.` }/>;
} else {
content = (
<>
<Show below="lg" ssr={ false }>
<AddressIntTxsList data={ data.items } currentAddress={ queryIdStr }/>
</Show>
<Hide below="lg" ssr={ false }>
<AddressIntTxsTable data={ data.items } currentAddress={ queryIdStr }/>
</Hide>
</>
);
}
return (
<Element name={ SCROLL_ELEM }>
<ActionBar mt={ -6 }>
<AddressTxsFilter
defaultFilter={ filterValue }
onFilterChange={ handleFilterChange }
isActive={ Boolean(filterValue) }
/>
{ !isPaginatorHidden && <Pagination ml="auto" { ...pagination }/> }
</ActionBar>
{ content }
</Element>
);
};
export default AddressInternalTxs;
import castArray from 'lodash/castArray';
import { useRouter } from 'next/router';
import React from 'react';
import { QueryKeys } from 'types/client/queries';
import TokenTransfer from 'ui/shared/TokenTransfer/TokenTransfer';
const AddressTokenTransfers = () => {
const router = useRouter();
const hash = router.query.id;
return (
<TokenTransfer
path={ `/node-api/addresses/${ hash }/token-transfers` }
queryName={ QueryKeys.addressTokenTransfers }
queryIds={ castArray(router.query.id) }
baseAddress={ typeof hash === 'string' ? hash : undefined }
/>
);
};
export default AddressTokenTransfers;
import castArray from 'lodash/castArray';
import { useRouter } from 'next/router';
import React from 'react';
import { Element } from 'react-scroll';
import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import { QueryKeys } from 'types/client/queries';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import ActionBar from 'ui/shared/ActionBar';
......@@ -12,15 +16,7 @@ import TxsContent from 'ui/txs/TxsContent';
import AddressTxsFilter from './AddressTxsFilter';
const FILTER_VALUES = [ 'from', 'to' ] as const;
type FilterType = typeof FILTER_VALUES[number];
const getFilterValue = (val: string | Array<string> | undefined): FilterType | undefined => {
if (typeof val === 'string' && FILTER_VALUES.includes(val as FilterType)) {
return val as FilterType;
}
};
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const SCROLL_ELEM = 'address-txs';
const SCROLL_OFFSET = -100;
......@@ -30,11 +26,12 @@ const AddressTxs = () => {
const isMobile = useIsMobile();
const [ filterValue, setFilterValue ] = React.useState<'from' | 'to' | undefined>(getFilterValue(router.query.filter));
const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(getFilterValue(router.query.filter));
const addressTxsQuery = useQueryWithPages({
apiPath: `/node-api/addresses/${ router.query.id }/transactions`,
queryName: QueryKeys.addressTxs,
queryIds: castArray(router.query.id),
filters: { filter: filterValue },
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET },
});
......
......@@ -8,11 +8,13 @@ import {
} from '@chakra-ui/react';
import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import FilterButton from 'ui/shared/FilterButton';
interface Props {
isActive: boolean;
defaultFilter: 'from' | 'to' | undefined;
defaultFilter: AddressFromToFilter;
onFilterChange: (nextValue: string | Array<string>) => void;
}
......
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);
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;
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);
import { Box } from '@chakra-ui/react';
import React from 'react';
const AddressCoinBalanceChart = () => {
// chart will be added after stats feature is finalized
return <Box p={ 4 } borderColor="gray.200" borderRadius="md" borderWidth="1px">Here will be coin balance chart</Box>;
};
export default AddressCoinBalanceChart;
import { Box, Hide, Show, Table, Tbody, Th, Tr } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { AddressCoinBalanceHistoryResponse } from 'types/api/address';
import appConfig from 'configs/app/config';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
import SkeletonTable from 'ui/shared/SkeletonTable';
import { default as Thead } from 'ui/shared/TheadSticky';
import AddressCoinBalanceListItem from './AddressCoinBalanceListItem';
import AddressCoinBalanceSkeletonMobile from './AddressCoinBalanceSkeletonMobile';
import AddressCoinBalanceTableItem from './AddressCoinBalanceTableItem';
interface Props {
query: UseQueryResult<AddressCoinBalanceHistoryResponse> & {
pagination: PaginationProps;
};
}
const AddressCoinBalanceHistory = ({ query }: Props) => {
const isPaginatorHidden = !query.isLoading && !query.isError && query.pagination.page === 1 && !query.pagination.hasNextPage;
const content = (() => {
if (query.isLoading) {
return (
<>
<Hide below="lg">
<SkeletonTable columns={ [ '25%', '25%', '25%', '25%', '120px' ] }/>
</Hide>
<Show below="lg">
<AddressCoinBalanceSkeletonMobile/>
</Show>
</>
);
}
if (query.isError) {
return <DataFetchAlert/>;
}
return (
<>
<Hide below="lg">
<Table variant="simple" size="sm">
<Thead top={ 80 }>
<Tr>
<Th width="25%">Block</Th>
<Th width="25%">Txn</Th>
<Th width="25%">Age</Th>
<Th width="25%" isNumeric pr={ 1 }/>
<Th width="120px" isNumeric>Balance { appConfig.network.currency.symbol }</Th>
</Tr>
</Thead>
<Tbody>
{ query.data.items.map((item) => (
<AddressCoinBalanceTableItem key={ item.block_number } { ...item } page={ query.pagination.page }/>
)) }
</Tbody>
</Table>
</Hide>
<Show below="lg">
{ query.data.items.map((item) => (
<AddressCoinBalanceListItem key={ item.block_number } { ...item } page={ query.pagination.page }/>
)) }
</Show>
</>
);
})();
return (
<Box mt={ 8 }>
{ !isPaginatorHidden && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...query.pagination }/>
</ActionBar>
) }
{ content }
</Box>
);
};
export default React.memo(AddressCoinBalanceHistory);
import { Link, Text, Stat, StatHelpText, StatArrow, Flex } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import appConfig from 'configs/app/config';
import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import link from 'lib/link/link';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import Address from 'ui/shared/address/Address';
import AddressLink from 'ui/shared/address/AddressLink';
type Props = AddressCoinBalanceHistoryItem & {
page: number;
};
const AddressCoinBalanceListItem = (props: Props) => {
const blockUrl = link('block', { id: String(props.block_number) });
const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return (
<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">
<StatHelpText display="flex" mb={ 0 } alignItems="center">
<StatArrow type={ isPositiveDelta ? 'increase' : 'decrease' }/>
<Text as="span" color={ isPositiveDelta ? 'green.500' : 'red.500' } fontWeight={ 600 }>
{ deltaBn.toFixed(8) }
</Text>
</StatHelpText>
</Stat>
</Flex>
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 } flexShrink={ 0 }>Block</Text>
<Link href={ blockUrl } fontWeight="700">{ props.block_number }</Link>
</Flex>
{ props.transaction_hash && (
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 } flexShrink={ 0 }>Txs</Text>
<Address maxW="150px" fontWeight="700">
<AddressLink hash={ props.transaction_hash } type="transaction"/>
</Address>
</Flex>
) }
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 } flexShrink={ 0 }>Age</Text>
<Text variant="secondary">{ timeAgo }</Text>
</Flex>
</AccountListItemMobile>
);
};
export default React.memo(AddressCoinBalanceListItem);
import { Skeleton, Flex, Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
const AddressCoinBalanceSkeletonMobile = () => {
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="170px"/>
<Skeleton w="120px"/>
</Flex>
<Flex h={ 6 } columnGap={ 2 }>
<Skeleton w="40px"/>
<Skeleton w="80px"/>
</Flex>
<Flex h={ 6 } columnGap={ 2 }>
<Skeleton w="40px"/>
<Skeleton w="150px"/>
</Flex>
<Flex h={ 6 } columnGap={ 2 }>
<Skeleton w="30px"/>
<Skeleton w="60px"/>
</Flex>
</Flex>
)) }
</Box>
);
};
export default AddressCoinBalanceSkeletonMobile;
import { Link, Td, Tr, Text, Stat, StatHelpText, StatArrow } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressLink from 'ui/shared/address/AddressLink';
type Props = AddressCoinBalanceHistoryItem & {
page: number;
};
const AddressCoinBalanceTableItem = (props: Props) => {
const blockUrl = link('block', { id: String(props.block_number) });
const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return (
<Tr>
<Td>
<Link href={ blockUrl } fontWeight="700">{ props.block_number }</Link>
</Td>
<Td>
{ props.transaction_hash ?
(
<Address maxW="150px" fontWeight="700">
<AddressLink hash={ props.transaction_hash } type="transaction"/>
</Address>
) :
<Text fontWeight="700">-</Text>
}
</Td>
<Td>
<Text variant="secondary">{ timeAgo }</Text>
</Td>
<Td isNumeric pr={ 1 }>
<Text>{ BigNumber(props.value).div(WEI).toFixed(8) }</Text>
</Td>
<Td isNumeric display="flex" justifyContent="end">
<Stat flexGrow="0">
<StatHelpText display="flex" mb={ 0 } alignItems="center">
<StatArrow type={ isPositiveDelta ? 'increase' : 'decrease' }/>
<Text as="span" color={ isPositiveDelta ? 'green.500' : 'red.500' } fontWeight={ 600 }>
{ deltaBn.toFixed(8) }
</Text>
</StatHelpText>
</Stat>
</Td>
</Tr>
);
};
export default React.memo(AddressCoinBalanceTableItem);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import AddressIntTxsListItem from 'ui/address/internals/AddressIntTxsListItem';
type Props = {
data: Array<InternalTransaction>;
currentAddress: string;
}
const AddressIntTxsList = ({ data, currentAddress }: Props) => {
return (
<Box>
{ data.map((item) => <AddressIntTxsListItem key={ item.transaction_hash } { ...item } currentAddress={ currentAddress }/>) }
</Box>
);
};
export default AddressIntTxsList;
import { Flex, Tag, Icon, Box, HStack, Text, Link } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import appConfig from 'configs/app/config';
import eastArrowIcon from 'icons/arrows/east.svg';
import dayjs from 'lib/date/dayjs';
import link from 'lib/link/link';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import InOutTag from 'ui/shared/InOutTag';
import TxStatus from 'ui/shared/TxStatus';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string };
const TxInternalsListItem = ({
type,
from,
to,
value,
success,
error,
created_contract: createdContract,
transaction_hash: txnHash,
block,
timestamp,
currentAddress,
}: Props) => {
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
const toData = to ? to : createdContract;
const isOut = Boolean(currentAddress && currentAddress === from.hash);
const isIn = Boolean(currentAddress && currentAddress === to?.hash);
return (
<AccountListItemMobile rowGap={ 3 }>
<Flex>
{ typeTitle && <Tag colorScheme="cyan" mr={ 2 }>{ typeTitle }</Tag> }
<TxStatus status={ success ? 'ok' : 'error' } errorText={ error }/>
</Flex>
<Flex justifyContent="space-between" width="100%">
<AddressLink fontWeight="700" hash={ txnHash } truncation="constant" type="transaction"/>
<Text variant="secondary" fontWeight="400" fontSize="sm">{ dayjs(timestamp).fromNow() }</Text>
</Flex>
<HStack spacing={ 1 }>
<Text fontSize="sm" fontWeight={ 500 }>Block</Text>
<Link href={ link('block', { id: block.toString() }) }>{ block }</Link>
</HStack>
<Box w="100%" display="flex" columnGap={ 3 }>
<Address width="calc((100% - 48px) / 2)">
<AddressIcon hash={ from.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash }/>
</Address>
{ (isIn || isOut) ?
<InOutTag isIn={ isIn } isOut={ isOut }/> :
<Icon as={ eastArrowIcon } boxSize={ 6 } color="gray.500"/>
}
<Address width="calc((100% - 48px) / 2)">
<AddressIcon hash={ toData.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ toData.hash }/>
</Address>
</Box>
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Value { appConfig.network.currency.symbol }</Text>
<Text fontSize="sm" variant="secondary">
{ BigNumber(value).div(BigNumber(10 ** appConfig.network.currency.decimals)).toFormat() }
</Text>
</HStack>
</AccountListItemMobile>
);
};
export default TxInternalsListItem;
import React from 'react';
import SkeletonTable from 'ui/shared/SkeletonTable';
const TxInternalsSkeletonDesktop = () => {
return (
<SkeletonTable columns={ [ '15%', '15%', '10%', '20%', '20%', '20%' ] }/>
);
};
export default TxInternalsSkeletonDesktop;
import { Skeleton, SkeletonCircle, Flex, Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
const TxInternalsSkeletonMobile = () => {
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 h={ 6 }>
<Skeleton w="100px" mr={ 2 }/>
<Skeleton w="90px"/>
</Flex>
<Flex h={ 6 }>
<Skeleton w="100%" mr={ 2 }/>
<Skeleton w="60px"/>
</Flex>
<Flex h={ 6 }>
<Skeleton w="70px" mr={ 2 }/>
<Skeleton w="30px"/>
</Flex>
<Flex h={ 6 }>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
<Skeleton w={ 6 } mr={ 3 }/>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
</Flex>
<Flex h={ 6 }>
<Skeleton w="70px" mr={ 2 }/>
<Skeleton w="30px"/>
</Flex>
</Flex>
)) }
</Box>
);
};
export default TxInternalsSkeletonMobile;
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import appConfig from 'configs/app/config';
import { default as Thead } from 'ui/shared/TheadSticky';
import AddressIntTxsTableItem from './AddressIntTxsTableItem';
interface Props {
data: Array<InternalTransaction>;
currentAddress: string;
}
const AddressIntTxsTable = ({ data, currentAddress }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ 80 }>
<Tr>
<Th width="15%">Parent txn hash</Th>
<Th width="15%">Type</Th>
<Th width="10%">Block</Th>
<Th width="20%">From</Th>
<Th width="48px" px={ 0 }/>
<Th width="20%">To</Th>
<Th width="20%" isNumeric>
Value { appConfig.network.currency.symbol }
</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<AddressIntTxsTableItem key={ item.transaction_hash } { ...item } currentAddress={ currentAddress }/>
)) }
</Tbody>
</Table>
);
};
export default AddressIntTxsTable;
import { Tr, Td, Tag, Icon, Box, Flex, Text, Link } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import appConfig from 'configs/app/config';
import rightArrowIcon from 'icons/arrows/east.svg';
import dayjs from 'lib/date/dayjs';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import InOutTag from 'ui/shared/InOutTag';
import TxStatus from 'ui/shared/TxStatus';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string }
const AddressIntTxsTableItem = ({
type,
from,
to,
value,
success,
error,
created_contract: createdContract,
transaction_hash: txnHash,
block,
timestamp,
currentAddress,
}: Props) => {
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
const toData = to ? to : createdContract;
const isOut = Boolean(currentAddress && currentAddress === from.hash);
const isIn = Boolean(currentAddress && currentAddress === to?.hash);
return (
<Tr alignItems="top">
<Td verticalAlign="middle">
<Flex rowGap={ 3 } flexWrap="wrap">
<AddressLink fontWeight="700" hash={ txnHash } type="transaction"/>
<Text variant="secondary" fontWeight="400" fontSize="sm">{ dayjs(timestamp).fromNow() }</Text>
</Flex>
</Td>
<Td verticalAlign="middle">
<Flex rowGap={ 2 } flexWrap="wrap">
{ typeTitle && (
<Box w="126px" display="inline-block">
<Tag colorScheme="cyan" mr={ 5 }>{ typeTitle }</Tag>
</Box>
) }
<TxStatus status={ success ? 'ok' : 'error' } errorText={ error }/>
</Flex>
</Td>
<Td verticalAlign="middle">
<Link href={ link('block', { id: block.toString() }) }>{ block }</Link>
</Td>
<Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%">
<AddressIcon hash={ from.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 }/>
</Address>
</Td>
<Td px={ 0 } verticalAlign="middle">
{ (isIn || isOut) ?
<InOutTag isIn={ isIn } isOut={ isOut }/> :
<Icon as={ rightArrowIcon } boxSize={ 6 } color="gray.500"/>
}
</Td>
<Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%">
<AddressIcon hash={ toData.hash }/>
<AddressLink hash={ toData.hash } alias={ toData.name } fontWeight="500" ml={ 2 }/>
</Address>
</Td>
<Td isNumeric verticalAlign="middle">
{ BigNumber(value).div(BigNumber(10 ** appConfig.network.currency.decimals)).toFormat() }
</Td>
</Tr>
);
};
export default React.memo(AddressIntTxsTableItem);
......@@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react';
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as coinBalanceMock from 'mocks/address/coinBalanceHistory';
import * as tokenBalanceMock from 'mocks/address/tokenBalance';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
......@@ -184,9 +185,7 @@ test.describe('socket', () => {
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:1');
socketServer.sendMessage(socket, channel, 'coin_balance', {
coin_balance: {
block_number: 1,
},
coin_balance: coinBalanceMock.base,
});
const button = page.getByRole('button', { name: /select/i });
......
......@@ -138,13 +138,13 @@ const LatestBlocksItem = ({ tx }: Props) => {
</Address>
</Flex>
<Flex fontSize="sm" justifyContent="end" flexDirection={{ base: 'column', lg: 'row' }}>
<Box mr={{ base: 0, lg: 2 }} mb={{ base: 2, lg: 0 }}>
<Box mr={{ base: 0, lg: 3 }} mb={{ base: 2, lg: 0 }}>
<Text as="span">Value { appConfig.network.currency.symbol } </Text>
<Text as="span" variant="secondary">{ getValueWithUnit(tx.value).toFormat() }</Text>
<Text as="span" variant="secondary">{ getValueWithUnit(tx.value).dp(5).toFormat() }</Text>
</Box>
<Box>
<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).dp(5).toFormat() }</Text>
</Box>
</Flex>
</Box>
......
......@@ -15,7 +15,7 @@ interface Props {
caption?: string;
}
const CHART_MARGIN = { bottom: 0, left: 10, right: 10, top: 0 };
const CHART_MARGIN = { bottom: 5, left: 10, right: 10, top: 0 };
const ChainIndicatorChart = ({ data }: Props) => {
const ref = React.useRef<SVGSVGElement>(null);
......
......@@ -8,7 +8,11 @@ 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 AddressInternalTxs from 'ui/address/AddressInternalTxs';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -34,10 +38,13 @@ const AddressPageContent = () => {
const tabs: Array<RoutedTab> = [
{ id: 'txs', title: 'Transactions', component: <AddressTxs/> },
{ id: 'token_transfers', title: 'Token transfers', component: null },
{ id: 'token_transfers', title: 'Token transfers', component: <AddressTokenTransfers/> },
{ id: 'tokens', title: 'Tokens', component: null },
{ id: 'internal_txn', title: 'Internal txn', component: null },
{ id: 'coin_balance_history', title: 'Coin balance history', component: null },
{ id: 'internal_txn', title: 'Internal txn', component: <AddressInternalTxs/> },
{ 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 (
......
......@@ -2,7 +2,7 @@ import { Box, Icon, Link } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { JsonRpcUrlResponse } from 'types/api/json-rpc-url';
import type { JsonRpcUrlResponse } from 'types/api/jsonRpcUrl';
import config from 'configs/app/config';
import PlusIcon from 'icons/plus.svg';
......
......@@ -43,5 +43,5 @@ test('default view -@default +@desktop-xl +@mobile +@dark-mode', async({ mount,
await page.evaluate(insertAdText);
await expect(component).toHaveScreenshot();
await expect(component.locator('main')).toHaveScreenshot();
});
......@@ -2,7 +2,7 @@ import { Box, Center, useColorMode } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { JsonRpcUrlResponse } from 'types/api/json-rpc-url';
import type { JsonRpcUrlResponse } from 'types/api/jsonRpcUrl';
import type { AppItemOverview } from 'types/client/apps';
import { QueryKeys } from 'types/client/queries';
......
......@@ -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 {
......
import { Text, Alert, Link, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
className?: string;
}
const SocketAlert = ({ className }: Props) => {
return (
<Alert status="warning" className={ className }>
<Text whiteSpace="pre">Connection lost, click </Text>
<Link href={ window.document.location.href }>to load newer records</Link>
</Alert>
);
};
export default chakra(SocketAlert);
import { Hide, Show, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { Element } from 'react-scroll';
import type { AddressTokenTransferFilters, AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo';
import type { TokenTransferFilters } from 'types/api/tokenTransfer';
import type { QueryKeys } from 'types/client/queries';
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';
......@@ -17,11 +24,21 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferSkeletonMobile from 'ui/shared/TokenTransfer/TokenTransferSkeletonMobile';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import { TOKEN_TYPE } from './helpers';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const SCROLL_ELEM = 'token-transfers';
const SCROLL_OFFSET = -100;
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
interface Props {
isLoading?: boolean;
isDisabled?: boolean;
path: string;
queryName: QueryKeys.txTokenTransfers;
queryName: QueryKeys.txTokenTransfers | QueryKeys.addressTokenTransfers;
queryIds?: Array<string>;
baseAddress?: string;
showTxInfo?: boolean;
......@@ -29,20 +46,33 @@ interface Props {
}
const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryIds, path, baseAddress, showTxInfo = true }: Props) => {
const [ filters, setFilters ] = React.useState<Array<TokenType>>([]);
const { isError, isLoading, data, pagination } = useQueryWithPages({
const router = useRouter();
const [ filters, setFilters ] = React.useState<AddressTokenTransferFilters & TokenTransferFilters>(
{ type: getTokenFilterValue(router.query.type), filter: getAddressFilterValue(router.query.filter) },
);
const { isError, isLoading, data, pagination, onFilterChange } = useQueryWithPages({
apiPath: path,
queryName,
queryIds,
options: { enabled: !isDisabled },
filters: filters.length ? { type: filters } : undefined,
filters: filters,
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET },
});
const handleFilterChange = React.useCallback((nextValue: Array<TokenType>) => {
setFilters(nextValue);
}, []);
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 isActionBarHidden = filters.length === 0 && !data?.items.length;
const numActiveFilters = filters.type.length + (filters.filter ? 1 : 0);
const isActionBarHidden = !numActiveFilters && !data?.items.length;
const content = (() => {
if (isLoading || isLoadingProp) {
......@@ -65,7 +95,7 @@ const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryI
return <DataFetchAlert/>;
}
if (!data.items?.length && filters.length === 0) {
if (!data.items?.length && !numActiveFilters) {
return <Text as="span">There are no token transfers</Text>;
}
......@@ -87,15 +117,22 @@ const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryI
})();
return (
<>
<Element name={ SCROLL_ELEM }>
{ !isActionBarHidden && (
<ActionBar mt={ -6 }>
<TokenTransferFilter defaultFilters={ filters } onFilterChange={ handleFilterChange } appliedFiltersNum={ filters.length }/>
<TokenTransferFilter
defaultTypeFilters={ filters.type }
onTypeFilterChange={ handleTypeFilterChange }
appliedFiltersNum={ numActiveFilters }
withAddressFilter={ Boolean(baseAddress) }
onAddressFilterChange={ handleAddressFilterChange }
defaultAddressFilter={ filters.filter }
/>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) }
{ content }
</>
</Element>
);
};
......
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, CheckboxGroup, Checkbox, Text, useDisclosure } from '@chakra-ui/react';
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
CheckboxGroup,
Checkbox,
Text,
useDisclosure,
Radio,
RadioGroup,
Stack,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo';
import FilterButton from 'ui/shared/FilterButton';
......@@ -9,13 +23,25 @@ import { TOKEN_TYPE } from './helpers';
interface Props {
appliedFiltersNum?: number;
defaultFilters: Array<TokenType>;
onFilterChange: (nextValue: Array<TokenType>) => void;
defaultTypeFilters: Array<TokenType>;
onTypeFilterChange: (nextValue: Array<TokenType>) => void;
withAddressFilter?: boolean;
onAddressFilterChange?: (nextValue: string) => void;
defaultAddressFilter?: AddressFromToFilter;
}
const TokenTransfer = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Props) => {
const TokenTransferFilter = ({
onTypeFilterChange,
defaultTypeFilters,
appliedFiltersNum,
withAddressFilter,
onAddressFilterChange,
defaultAddressFilter,
}: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
......@@ -27,8 +53,27 @@ const TokenTransfer = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Pr
</PopoverTrigger>
<PopoverContent w="200px">
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 5 }>
{ withAddressFilter && (
<>
<Text variant="secondary" fontWeight={ 600 }>Address</Text>
<RadioGroup
size="lg"
onChange={ onAddressFilterChange }
defaultValue={ defaultAddressFilter || 'all' }
paddingBottom={ 4 }
borderBottom="1px solid"
borderColor={ borderColor }
>
<Stack spacing={ 4 }>
<Radio value="all"><Text fontSize="md">All</Text></Radio>
<Radio value="from"><Text fontSize="md">From</Text></Radio>
<Radio value="to"><Text fontSize="md">To</Text></Radio>
</Stack>
</RadioGroup>
</>
) }
<Text variant="secondary" fontWeight={ 600 }>Type</Text>
<CheckboxGroup size="lg" onChange={ onFilterChange } defaultValue={ defaultFilters }>
<CheckboxGroup size="lg" onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters }>
{ TOKEN_TYPE.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</PopoverBody>
......@@ -37,4 +82,4 @@ const TokenTransfer = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Pr
);
};
export default React.memo(TokenTransfer);
export default React.memo(TokenTransferFilter);
......@@ -14,7 +14,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null);
const textColorToken = useColorModeValue('blackAlpha.500', 'whiteAlpha.500');
const textColorToken = useColorModeValue('blackAlpha.600', 'whiteAlpha.500');
const textColor = useToken('colors', textColorToken);
React.useEffect(() => {
......
......@@ -13,7 +13,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null);
const strokeColorToken = useColorModeValue('blackAlpha.300', 'whiteAlpha.300');
const strokeColorToken = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const strokeColor = useToken('colors', strokeColorToken);
React.useEffect(() => {
......
import { Box, Grid, Heading, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Text, useColorModeValue, VisuallyHidden } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { Box, Grid, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Text, Tooltip, useColorModeValue, VisuallyHidden } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import type { Charts } from 'types/api/stats';
import { QueryKeys } from 'types/client/queries';
import type { StatsIntervalIds } from 'types/client/stats';
import type { TimeChartItem } from './types';
import repeatArrow from 'icons/repeat_arrow.svg';
import dotsIcon from 'icons/vertical_dots.svg';
import useFetch from 'lib/hooks/useFetch';
import ChartWidgetGraph from './ChartWidgetGraph';
import ChartWidgetSkeleton from './ChartWidgetSkeleton';
import { STATS_INTERVALS } from './constants';
import FullscreenChartModal from './FullscreenChartModal';
type Props = {
id: string;
items?: Array<TimeChartItem>;
title: string;
description: string;
interval: StatsIntervalIds;
isLoading: boolean;
}
function formatDate(date: Date) {
return date.toISOString().substring(0, 10);
}
const ChartWidget = ({ id, title, description, interval }: Props) => {
const fetch = useFetch();
const selectedInterval = STATS_INTERVALS[interval];
const ChartWidget = ({ items, title, description, isLoading }: Props) => {
const [ isFullscreen, setIsFullscreen ] = useState(false);
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
const endDate = selectedInterval.start ? formatDate(new Date()) : undefined;
const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined;
const menuButtonColor = useColorModeValue('black', 'white');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const url = `/node-api/stats/charts?name=${ id }${ startDate ? `&from=${ startDate }&to=${ endDate }` : '' }`;
const { data, isLoading } = useQuery<unknown, unknown, Charts>(
[ QueryKeys.charts, id, startDate ],
async() => await fetch(url),
);
const handleZoom = useCallback(() => {
setIsZoomResetInitial(false);
}, []);
......@@ -75,16 +51,11 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
return <ChartWidgetSkeleton/>;
}
if (data) {
const items = data.chart
.map((item) => {
return { date: new Date(item.date), value: Number(item.value) };
});
if (items) {
return (
<>
<Box
padding={{ base: 3, md: 4 }}
padding={{ base: 3, lg: 4 }}
borderRadius="md"
border="1px"
borderColor={ borderColor }
......@@ -93,12 +64,15 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
gridTemplateColumns="auto auto 36px"
gridColumnGap={ 2 }
>
<Heading
mb={ 1 }
size={{ base: 'xs', md: 'sm' }}
<Text
fontWeight={ 600 }
fontSize="md"
lineHeight={ 6 }
as="p"
size={{ base: 'xs', lg: 'sm' }}
>
{ title }
</Heading>
</Text>
<Text
mb={ 1 }
......@@ -110,22 +84,23 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
{ description }
</Text>
<IconButton
hidden={ isZoomResetInitial }
aria-label="Reset zoom"
title="Reset zoom"
colorScheme="blue"
w={ 9 }
h={ 8 }
gridColumn={ 2 }
justifySelf="end"
alignSelf="top"
gridRow="1/3"
size="sm"
variant="ghost"
onClick={ handleZoomResetClick }
icon={ <Icon as={ repeatArrow } w={ 4 } h={ 4 } color="blue.700"/> }
/>
<Tooltip label="Reset zoom">
<IconButton
hidden={ isZoomResetInitial }
aria-label="Reset zoom"
colorScheme="blue"
w={ 9 }
h={ 8 }
gridColumn={ 2 }
justifySelf="end"
alignSelf="top"
gridRow="1/3"
size="sm"
variant="outline"
onClick={ handleZoomResetClick }
icon={ <Icon as={ repeatArrow } w={ 4 } h={ 4 }/> }
/>
</Tooltip>
<Menu>
<MenuButton
......@@ -134,8 +109,9 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
justifySelf="end"
w="36px"
h="32px"
icon={ <Icon as={ dotsIcon } w={ 4 } h={ 4 } color={ menuButtonColor }/> }
colorScheme="transparent"
icon={ <Icon as={ dotsIcon } w={ 4 } h={ 4 }/> }
colorScheme="gray"
variant="ghost"
as={ IconButton }
>
<VisuallyHidden>
......@@ -160,6 +136,7 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
isOpen={ isFullscreen }
items={ items }
title={ title }
description={ description }
onClose={ clearFullscreenChart }
/>
</>
......
......@@ -15,20 +15,21 @@ import useChartSize from 'ui/shared/chart/useChartSize';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
interface Props {
isEnlarged?: boolean;
title: string;
items: Array<TimeChartItem>;
onZoom: () => void;
isZoomResetInitial: boolean;
}
const CHART_MARGIN = { bottom: 20, left: 52, right: 30, top: 10 };
const CHART_MARGIN = { bottom: 20, left: 30, right: 20, top: 10 };
const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) => {
const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title }: Props) => {
const isMobile = useIsMobile();
const ref = React.useRef<SVGSVGElement>(null);
const color = useToken('colors', 'blue.200');
const overlayRef = React.useRef<SVGRectElement>(null);
const chartId = useMemo(() => `chart-${ crypto.randomUUID() }`, []);
const chartId = useMemo(() => `chart-${ title.split(' ').join('') }`, [ title ]);
const [ range, setRange ] = React.useState<[ number, number ]>([ 0, Infinity ]);
const { innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN);
......@@ -56,11 +57,11 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) =
return (
<svg width="100%" height="100%" ref={ ref } cursor="pointer" id={ chartId }>
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ 1 }>
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ innerWidth ? 1 : 0 }>
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 3 }
ticks={ isEnlarged ? 6 : 3 }
size={ innerWidth }
disableAnimation
/>
......@@ -78,28 +79,28 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) =
xScale={ xScale }
yScale={ yScale }
stroke={ color }
animation="left"
strokeWidth={ 3 }
animation="none"
strokeWidth={ isMobile ? 1 : 2 }
/>
<ChartAxis
type="left"
scale={ yScale }
ticks={ 5 }
ticks={ isEnlarged ? 6 : 3 }
tickFormat={ yTickFormat }
disableAnimation
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartAxis
type="bottom"
scale={ xScale }
transform={ `translate(0, ${ innerHeight })` }
ticks={ isMobile ? 1 : 3 }
anchorEl={ overlayRef.current }
disableAnimation
/>
<ChartAxis
type="bottom"
scale={ xScale }
transform={ `translate(0, ${ innerHeight })` }
ticks={ isMobile ? 1 : 4 }
anchorEl={ overlayRef.current }
disableAnimation
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartTooltip
chartId={ chartId }
anchorEl={ overlayRef.current }
......
......@@ -5,7 +5,7 @@ const ChartWidgetSkeleton = () => {
return (
<Box
height="235px"
paddingY={{ base: 3, md: 4 }}
paddingY={{ base: 3, lg: 4 }}
>
<Skeleton w="75%" h="24px" mb={ 1 }/>
<Skeleton w="50%" h="18px" mb={ 5 }/>
......
import { Button, Flex, Heading, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import { Box, Button, Grid, Heading, Icon, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay, Text } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { TimeChartItem } from '../shared/chart/types';
import type { TimeChartItem } from './types';
import repeatArrow from 'icons/repeat_arrow.svg';
import ChartWidgetGraph from './ChartWidgetGraph';
type Props = {
isOpen: boolean;
title: string;
description: string;
items: Array<TimeChartItem>;
onClose: () => void;
}
......@@ -15,6 +18,7 @@ type Props = {
const FullscreenChartModal = ({
isOpen,
title,
description,
items,
onClose,
}: Props) => {
......@@ -39,44 +43,53 @@ const FullscreenChartModal = ({
<ModalContent>
<ModalHeader>
<Flex
alignItems="center"
<Box
mb={ 1 }
>
<Grid
gridColumnGap={ 2 }
>
<Heading
as="h2"
gridColumn={ 2 }
fontSize={{ base: '2xl', sm: '3xl' }}
fontWeight="medium"
lineHeight={ 1 }
color="blue.600"
mb={ 1 }
size={{ base: 'xs', sm: 'md' }}
>
{ title }
</Heading>
<Text
gridColumn={ 1 }
as="p"
variant="secondary"
fontSize="xs"
>
{ description }
</Text>
{ !isZoomResetInitial && (
<Button
ml="auto"
leftIcon={ <Icon as={ repeatArrow } w={ 4 } h={ 4 }/> }
colorScheme="blue"
gridColumn={ 2 }
justifySelf="end"
alignSelf="top"
gridRow="1/3"
size="md"
size="sm"
variant="outline"
onClick={ handleZoomResetClick }
>
Reset zoom
Reset zoom
</Button>
) }
</Flex>
</ModalHeader>
</Grid>
</Box>
<ModalCloseButton/>
<ModalBody
h="75%"
h="100%"
>
<ChartWidgetGraph
isEnlarged
items={ items }
onZoom={ handleZoom }
isZoomResetInitial={ isZoomResetInitial }
......
......@@ -16,18 +16,26 @@ export default function useChartSize(svgEl: SVGSVGElement | null, margin?: Chart
}, [ calculateRect ]);
React.useEffect(() => {
const content = window.document.querySelector('main');
if (!content) {
return;
}
let timeoutId: number;
const resizeHandler = _debounce(() => {
setRect({ width: 0, height: 0 });
timeoutId = window.setTimeout(() => {
setRect(calculateRect());
}, 0);
}, 100);
}, 200);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(content);
resizeObserver.observe(window.document.body);
resizeObserver.observe(document.body);
return function cleanup() {
resizeObserver.unobserve(document.body);
resizeObserver.unobserve(content);
resizeObserver.unobserve(window.document.body);
window.clearTimeout(timeoutId);
};
}, [ calculateRect ]);
......
......@@ -37,7 +37,8 @@ export default function useTimeChartController({ data, width, height }: Props) {
);
const yScale = useMemo(() => {
const indention = (yMax - yMin) * 0.3;
const indention = (yMax - yMin) * 0.15;
return d3.scaleLinear()
.domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ])
.range([ height, 0 ]);
......
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import authFixture from 'playwright/fixtures/auth';
import TestApp from 'playwright/TestApp';
import Burger from './Burger';
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page }) => {
const component = await mount(
<TestApp>
<Burger/>
</TestApp>,
);
await component.locator('svg[aria-label="Menu button"]').click();
await expect(page).toHaveScreenshot();
await page.locator('button[aria-label="Network menu"]').click();
await expect(page).toHaveScreenshot();
});
test.describe('dark mode', () => {
test.use({ colorScheme: 'dark' });
test('base view', async({ mount, page }) => {
const component = await mount(
<TestApp>
<Burger/>
</TestApp>,
);
await component.locator('svg[aria-label="Menu button"]').click();
await expect(page).toHaveScreenshot();
await page.locator('button[aria-label="Network menu"]').click();
await expect(page).toHaveScreenshot();
});
});
test.describe('auth', () => {
const extendedTest = test.extend({
context: ({ context }, use) => {
authFixture(context);
use(context);
},
});
extendedTest('base view', async({ mount, page }) => {
const component = await mount(
<TestApp>
<Burger/>
</TestApp>,
);
await component.locator('svg[aria-label="Menu button"]').click();
await expect(page).toHaveScreenshot();
});
});
......@@ -29,6 +29,7 @@ const Burger = () => {
boxSize={ 6 }
display="block"
color={ iconColor }
aria-label="Menu button"
/>
</Box>
<Drawer
......
......@@ -89,6 +89,7 @@ const ColorModeToggler = forwardRef<ColorModeTogglerProps, 'input'>((props, ref)
<chakra.div
{ ...getCheckboxProps() }
__css={ trackStyles }
aria-label="Toggle color mode"
>
<Icon
boxSize={ 4 }
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import Header from './Header';
test('no auth +@mobile +@dark-mode +@dark-mode-mobile', async({ mount, page }) => {
await mount(
<TestApp>
<Header/>
</TestApp>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 150 } });
});
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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