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 name: Run E2E tests k8s
on: on:
# push: pull_request_review:
pull_request: types: [submitted]
workflow_dispatch: workflow_dispatch:
env: env:
...@@ -14,59 +14,13 @@ env: ...@@ -14,59 +14,13 @@ env:
BASTION_SSH_KEY: ${{secrets.BASTION_SSH_KEY}} BASTION_SSH_KEY: ${{secrets.BASTION_SSH_KEY}}
jobs: 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: 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 uses: blockscout/blockscout-ci-cd/.github/workflows/e2e_new.yaml@master
with: with:
appNamespace: e2e-front-$GITHUB_SHA_SHORT 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 blockscoutIngressHost: e2e-blockscout-$GITHUB_SHA_SHORT
frontendIngressHost: e2e-blockscout-$GITHUB_SHA_SHORT frontendIngressHost: e2e-blockscout-$GITHUB_SHA_SHORT
gethIngressHost: e2e-geth-$GITHUB_SHA_SHORT gethIngressHost: e2e-geth-$GITHUB_SHA_SHORT
......
...@@ -58,7 +58,6 @@ jobs: ...@@ -58,7 +58,6 @@ jobs:
file: ./Dockerfile file: ./Dockerfile
push: true push: true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max
tags: ghcr.io/blockscout/frontend:prerelease-${{ env.GITHUB_REF_NAME_SLUG }} tags: ghcr.io/blockscout/frontend:prerelease-${{ env.GITHUB_REF_NAME_SLUG }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
...@@ -69,12 +68,12 @@ jobs: ...@@ -69,12 +68,12 @@ jobs:
needs: push_to_registry needs: push_to_registry
uses: blockscout/blockscout-ci-cd/.github/workflows/deploy.yaml@master uses: blockscout/blockscout-ci-cd/.github/workflows/deploy.yaml@master
with: 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 globalEnv: review
appNamespace: review-front-$GITHUB_REF_NAME_SLUG appNamespace: review-front-$GITHUB_REF_NAME_SLUG
blockscoutIngressHost: blockscout blockscoutIngressHost: review-front
frontendIngressHost: blockscout frontendIngressHost: review-front
frontendImage: ghcr.io/blockscout/frontend:prerelease-$GITHUB_REF_NAME_SLUG frontendImage: ghcr.io/blockscout/frontend:prerelease-$GITHUB_REF_NAME_SLUG
gethIngressHost: geth gethIngressHost: review-front-geth
scVerifierIngressHost: sc-verifier scVerifierIngressHost: review-front-sc-verifier
secrets: inherit secrets: inherit
...@@ -11,6 +11,8 @@ NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout ...@@ -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_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true
NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true
NEXT_PUBLIC_NETWORK_LOGO=
NEXT_PUBLIC_NETWORK_SMALL_LOGO=
# api config # api config
NEXT_PUBLIC_API_HOST=blockscout.com NEXT_PUBLIC_API_HOST=blockscout.com
......
...@@ -22,8 +22,12 @@ blockscout: ...@@ -22,8 +22,12 @@ blockscout:
# enable ingress # enable ingress
ingress: ingress:
enabled: true enabled: true
annotations: {} annotations:
# - 'nginx.ingress.kubernetes.io/rewrite-target: /$2' # - '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: host:
_default: blockscout.test.blockscout.aws-k8s.blockscout.com _default: blockscout.test.blockscout.aws-k8s.blockscout.com
# enable https # enable https
...@@ -158,7 +162,7 @@ postgres: ...@@ -158,7 +162,7 @@ postgres:
# strategy: Recreate # strategy: Recreate
persistence: true persistence: true
storage: 500Gi storage: 1000Gi
resources: resources:
limits: limits:
......
...@@ -394,4 +394,4 @@ frontend: ...@@ -394,4 +394,4 @@ frontend:
NEXT_PUBLIC_APP_HOST: NEXT_PUBLIC_APP_HOST:
_default: blockscout.com _default: blockscout.com
NEXT_PUBLIC_NETWORK_EXPLORERS: 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 'js-cookie';
import { Cookies } from 'typescript-cookie';
import isBrowser from './isBrowser'; import isBrowser from './isBrowser';
...@@ -14,10 +13,13 @@ export function get(name?: NAMES | undefined | null, serverCookie?: string) { ...@@ -14,10 +13,13 @@ export function get(name?: NAMES | undefined | null, serverCookie?: string) {
if (!isBrowser()) { if (!isBrowser()) {
return serverCookie ? getFromCookieString(serverCookie, name) : undefined; 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 = '/'; attributes.path = '/';
return Cookies.set(name, value, attributes); 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() { ...@@ -29,7 +29,7 @@ export default function useNavItems() {
{ text: 'Tokens', url: link('tokens'), icon: tokensIcon, isActive: currentRoute === 'tokens', isNewUi: false }, { text: 'Tokens', url: link('tokens'), icon: tokensIcon, isActive: currentRoute === 'tokens', isNewUi: false },
isMarketplaceFilled ? isMarketplaceFilled ?
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps', isNewUi: true } : null, { 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' // 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/ // 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 // 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>( ...@@ -85,7 +85,7 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const onPrevPageClick = useCallback(() => { const onPrevPageClick = useCallback(() => {
// returning to the first page // returning to the first page
// we dont have pagination params for 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) { if (page === 2) {
nextPageQuery = omit(router.query, paginationFields, 'page'); nextPageQuery = omit(router.query, paginationFields, 'page');
canGoBackwards.current = true; canGoBackwards.current = true;
...@@ -99,17 +99,24 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>( ...@@ -99,17 +99,24 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
.then(() => { .then(() => {
scrollToTop(); scrollToTop();
setPage(prev => prev - 1); 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(() => { const resetPage = useCallback(() => {
queryClient.removeQueries({ queryKey: [ queryName ] });
router.push({ pathname: router.pathname, query: omit(router.query, paginationFields, 'page') }, undefined, { shallow: true }).then(() => { router.push({ pathname: router.pathname, query: omit(router.query, paginationFields, 'page') }, undefined, { shallow: true }).then(() => {
queryClient.removeQueries({ queryKey: [ queryName ] }); queryClient.removeQueries({ queryKey: [ queryName ] });
scrollToTop(); scrollToTop();
setPage(1); setPage(1);
setPageParams([ ]); setPageParams([ ]);
canGoBackwards.current = true; 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 ]); }, [ queryClient, queryName, router, paginationFields, scrollToTop ]);
...@@ -117,7 +124,7 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>( ...@@ -117,7 +124,7 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const newQuery = omit(router.query, PAGINATION_FIELDS[queryName], 'page', PAGINATION_FILTERS_FIELDS[queryName]); const newQuery = omit(router.query, PAGINATION_FIELDS[queryName], 'page', PAGINATION_FILTERS_FIELDS[queryName]);
if (newFilters) { if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => { Object.entries(newFilters).forEach(([ key, value ]) => {
if (value) { if (value && value.length) {
newQuery[key] = Array.isArray(value) ? value.join(',') : (value || ''); newQuery[key] = Array.isArray(value) ? value.join(',') : (value || '');
} }
}); });
......
import type { Channel } from 'phoenix'; import type { Channel } from 'phoenix';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block'; import type { NewBlockSocketResponse } from 'types/api/block';
export type SocketMessageParams = SocketMessage.NewBlock | export type SocketMessageParams = SocketMessage.NewBlock |
...@@ -19,16 +20,6 @@ interface SocketMessageParamsGeneric<Event extends string | undefined, Payload e ...@@ -19,16 +20,6 @@ interface SocketMessageParamsGeneric<Event extends string | undefined, Payload e
handler: (payload: Payload) => void; 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 // eslint-disable-next-line @typescript-eslint/no-namespace
export namespace SocketMessage { export namespace SocketMessage {
export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>; export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>;
...@@ -40,6 +31,6 @@ export namespace SocketMessage { ...@@ -40,6 +31,6 @@ export namespace SocketMessage {
export type AddressCurrentCoinBalance = export type AddressCurrentCoinBalance =
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', AddressCoinBalancePayload>; export type AddressCoinBalance = SocketMessageParamsGeneric<'coin_balance', { coin_balance: AddressCoinBalanceHistoryItem }>;
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>; 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',
};
...@@ -43,6 +43,7 @@ ...@@ -43,6 +43,7 @@
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"ethers": "^5.7.1", "ethers": "^5.7.1",
"framer-motion": "^6.5.1", "framer-motion": "^6.5.1",
"js-cookie": "^3.0.1",
"lodash": "^4.0.0", "lodash": "^4.0.0",
"next": "12.2.5", "next": "12.2.5",
"node-fetch": "^3.2.9", "node-fetch": "^3.2.9",
...@@ -56,7 +57,6 @@ ...@@ -56,7 +57,6 @@
"react-identicons": "^1.2.5", "react-identicons": "^1.2.5",
"react-jazzicon": "^1.0.4", "react-jazzicon": "^1.0.4",
"react-scroll": "^1.8.7", "react-scroll": "^1.8.7",
"typescript-cookie": "^1.0.4",
"use-font-face-observer": "^1.2.1" "use-font-face-observer": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
...@@ -65,6 +65,7 @@ ...@@ -65,6 +65,7 @@
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@types/d3": "^7.4.0", "@types/d3": "^7.4.0",
"@types/jest": "^29.2.0", "@types/jest": "^29.2.0",
"@types/js-cookie": "^3.0.2",
"@types/node": "17.0.36", "@types/node": "17.0.36",
"@types/phoenix": "^1.5.4", "@types/phoenix": "^1.5.4",
"@types/qrcode": "^1.5.0", "@types/qrcode": "^1.5.0",
......
...@@ -24,7 +24,7 @@ const watchlistWithTokensHandler = async(_req: NextApiRequest, res: NextApiRespo ...@@ -24,7 +24,7 @@ const watchlistWithTokensHandler = async(_req: NextApiRequest, res: NextApiRespo
} }
const data = await Promise.all(watchlistData.map(async item => { 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; const tokensData = await tokens.json() as Tokenlist;
return ({ ...item, tokens_count: Array.isArray(tokensData.result) ? tokensData.result.length : 0 }); 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 = { ...@@ -96,6 +96,15 @@ const config: PlaywrightTestConfig = {
colorScheme: 'dark', 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'; ...@@ -2,6 +2,8 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react'; 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 { SocketProvider } from 'lib/socket/context';
import { PORT } from 'playwright/fixtures/socketServer'; import { PORT } from 'playwright/fixtures/socketServer';
import theme from 'theme'; import theme from 'theme';
...@@ -9,9 +11,19 @@ import theme from 'theme'; ...@@ -9,9 +11,19 @@ import theme from 'theme';
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
withSocket?: boolean; 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({ const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
...@@ -25,7 +37,9 @@ const TestApp = ({ children, withSocket }: Props) => { ...@@ -25,7 +37,9 @@ const TestApp = ({ children, withSocket }: Props) => {
<ChakraProvider theme={ theme }> <ChakraProvider theme={ theme }>
<QueryClientProvider client={ queryClient }> <QueryClientProvider client={ queryClient }>
<SocketProvider url={ withSocket ? `ws://localhost:${ PORT }` : undefined }> <SocketProvider url={ withSocket ? `ws://localhost:${ PORT }` : undefined }>
{ children } <AppContextProvider { ...appContext }>
{ children }
</AppContextProvider>
</SocketProvider> </SocketProvider>
</QueryClientProvider> </QueryClientProvider>
</ChakraProvider> </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'; ...@@ -2,7 +2,7 @@ import type { TestFixture, Page } from '@playwright/test';
import type { WebSocket } from 'ws'; import type { WebSocket } from 'ws';
import { WebSocketServer } 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'; import type { NewBlockSocketResponse } from 'types/api/block';
type ReturnType = () => Promise<WebSocket>; type ReturnType = () => Promise<WebSocket>;
...@@ -54,7 +54,7 @@ export const joinChannel = async(socket: WebSocket, channelName: string) => { ...@@ -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: '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: 'transaction', payload: { transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
......
...@@ -25,10 +25,8 @@ do ...@@ -25,10 +25,8 @@ do
configValue="$(cut -d'=' -f2- <<<"$line")"; configValue="$(cut -d'=' -f2- <<<"$line")";
# if there is a value, escape it and add line to target file # if there is a value, escape it and add line to target file
if [ -n "$configValue" ]; then escapedConfigValue=$(echo $configValue | sed s/\'/\"/g);
escapedConfigValue=$(echo $configValue | sed s/\'/\"/g); echo "window.process.env.${configName} = localStorage.getItem('${configName}') || '${escapedConfigValue}';" >> $targetFile;
echo "window.process.env.${configName} = localStorage.getItem('${configName}') || '${escapedConfigValue}';" >> $targetFile;
fi
done < $envFile done < $envFile
done done
......
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
import type { AddressTag, WatchlistName } from './addressParams'; import type { AddressTag, WatchlistName } from './addressParams';
import type { Block } from './block';
import type { InternalTransaction } from './internalTransaction';
import type { TokenInfo, TokenType } from './tokenInfo'; import type { TokenInfo, TokenType } from './tokenInfo';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer'; import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
...@@ -44,7 +46,9 @@ export interface AddressTransactionsResponse { ...@@ -44,7 +46,9 @@ export interface AddressTransactionsResponse {
} | null; } | null;
} }
type AddressFromToFilter = 'from' | 'to' | undefined; export const AddressFromToFilterValues = [ 'from', 'to' ] as const;
export type AddressFromToFilter = typeof AddressFromToFilterValues[number] | undefined;
export type AddressTxsFilters = { export type AddressTxsFilters = {
filter: AddressFromToFilter; filter: AddressFromToFilter;
...@@ -57,5 +61,38 @@ export interface AddressTokenTransferResponse { ...@@ -57,5 +61,38 @@ export interface AddressTokenTransferResponse {
export type AddressTokenTransferFilters = { export type AddressTokenTransferFilters = {
filter: AddressFromToFilter; 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 { BlocksResponse, BlockTransactionsResponse, BlockFilters } from 'types/api/block';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { LogsResponse } from 'types/api/log'; import type { LogsResponse } from 'types/api/log';
...@@ -11,6 +19,7 @@ import type { KeysOfObjectOrNull } from 'types/utils/KeysOfObjectOrNull'; ...@@ -11,6 +19,7 @@ import type { KeysOfObjectOrNull } from 'types/utils/KeysOfObjectOrNull';
export type PaginatedQueryKeys = export type PaginatedQueryKeys =
QueryKeys.addressTxs | QueryKeys.addressTxs |
QueryKeys.addressTokenTransfers | QueryKeys.addressTokenTransfers |
QueryKeys.addressInternalTxs |
QueryKeys.blocks | QueryKeys.blocks |
QueryKeys.blocksReorgs | QueryKeys.blocksReorgs |
QueryKeys.blocksUncles | QueryKeys.blocksUncles |
...@@ -19,9 +28,12 @@ export type PaginatedQueryKeys = ...@@ -19,9 +28,12 @@ export type PaginatedQueryKeys =
QueryKeys.txsPending | QueryKeys.txsPending |
QueryKeys.txInternals | QueryKeys.txInternals |
QueryKeys.txLogs | QueryKeys.txLogs |
QueryKeys.txTokenTransfers; QueryKeys.txTokenTransfers |
QueryKeys.addressCoinBalanceHistory |
QueryKeys.addressBlocksValidated;
export type PaginatedResponse<Q extends PaginatedQueryKeys> = export type PaginatedResponse<Q extends PaginatedQueryKeys> =
Q extends QueryKeys.addressInternalTxs ? AddressInternalTxsResponse :
Q extends QueryKeys.addressTxs ? AddressTransactionsResponse : Q extends QueryKeys.addressTxs ? AddressTransactionsResponse :
Q extends QueryKeys.addressTokenTransfers ? AddressTokenTransferResponse : Q extends QueryKeys.addressTokenTransfers ? AddressTokenTransferResponse :
Q extends (QueryKeys.blocks | QueryKeys.blocksReorgs | QueryKeys.blocksUncles) ? BlocksResponse : Q extends (QueryKeys.blocks | QueryKeys.blocksReorgs | QueryKeys.blocksUncles) ? BlocksResponse :
...@@ -31,10 +43,12 @@ export type PaginatedResponse<Q extends PaginatedQueryKeys> = ...@@ -31,10 +43,12 @@ export type PaginatedResponse<Q extends PaginatedQueryKeys> =
Q extends QueryKeys.txInternals ? InternalTransactionsResponse : Q extends QueryKeys.txInternals ? InternalTransactionsResponse :
Q extends QueryKeys.txLogs ? LogsResponse : Q extends QueryKeys.txLogs ? LogsResponse :
Q extends QueryKeys.txTokenTransfers ? TokenTransferResponse : Q extends QueryKeys.txTokenTransfers ? TokenTransferResponse :
never Q extends QueryKeys.addressCoinBalanceHistory ? AddressCoinBalanceHistoryResponse :
Q extends QueryKeys.addressBlocksValidated ? AddressBlocksValidatedResponse :
never
export type PaginationFilters<Q extends PaginatedQueryKeys> = 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.addressTokenTransfers ? AddressTokenTransferFilters :
Q extends QueryKeys.blocks ? BlockFilters : Q extends QueryKeys.blocks ? BlockFilters :
Q extends QueryKeys.txsValidate ? TTxsFilters : Q extends QueryKeys.txsValidate ? TTxsFilters :
...@@ -50,6 +64,7 @@ type PaginationFields = { ...@@ -50,6 +64,7 @@ type PaginationFields = {
export const PAGINATION_FIELDS: PaginationFields = { export const PAGINATION_FIELDS: PaginationFields = {
[QueryKeys.addressTxs]: [ 'block_number', 'items_count', 'index' ], [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.addressTokenTransfers]: [ 'block_number', 'items_count', 'index', 'transaction_hash' ],
[QueryKeys.blocks]: [ 'block_number', 'items_count' ], [QueryKeys.blocks]: [ 'block_number', 'items_count' ],
[QueryKeys.blocksReorgs]: [ 'block_number', 'items_count' ], [QueryKeys.blocksReorgs]: [ 'block_number', 'items_count' ],
...@@ -60,6 +75,8 @@ export const PAGINATION_FIELDS: PaginationFields = { ...@@ -60,6 +75,8 @@ export const PAGINATION_FIELDS: PaginationFields = {
[QueryKeys.txInternals]: [ 'block_number', 'items_count', 'transaction_hash', 'index', 'transaction_index' ], [QueryKeys.txInternals]: [ 'block_number', 'items_count', 'transaction_hash', 'index', 'transaction_index' ],
[QueryKeys.txTokenTransfers]: [ 'block_number', 'items_count', 'transaction_hash', 'index' ], [QueryKeys.txTokenTransfers]: [ 'block_number', 'items_count', 'transaction_hash', 'index' ],
[QueryKeys.txLogs]: [ '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 = { type PaginationFiltersFields = {
...@@ -68,7 +85,10 @@ type PaginationFiltersFields = { ...@@ -68,7 +85,10 @@ type PaginationFiltersFields = {
export const PAGINATION_FILTERS_FIELDS: PaginationFiltersFields = { export const PAGINATION_FILTERS_FIELDS: PaginationFiltersFields = {
[QueryKeys.addressTxs]: [ 'filter' ], [QueryKeys.addressTxs]: [ 'filter' ],
[QueryKeys.addressInternalTxs]: [ 'filter' ],
[QueryKeys.addressTokenTransfers]: [ 'filter', 'type' ], [QueryKeys.addressTokenTransfers]: [ 'filter', 'type' ],
[QueryKeys.addressCoinBalanceHistory]: [],
[QueryKeys.addressBlocksValidated]: [],
[QueryKeys.blocks]: [ 'type' ], [QueryKeys.blocks]: [ 'type' ],
[QueryKeys.txsValidate]: [ 'filter', 'type', 'method' ], [QueryKeys.txsValidate]: [ 'filter', 'type', 'method' ],
[QueryKeys.txsPending]: [ 'filter', 'type', 'method' ], [QueryKeys.txsPending]: [ 'filter', 'type', 'method' ],
......
...@@ -20,12 +20,20 @@ export type GasPrices = { ...@@ -20,12 +20,20 @@ export type GasPrices = {
} }
export type Stats = { export type Stats = {
totalBlocksAllTime: string; counters: {
averageBlockTime: string;
completedTransactions: string;
totalAccounts: string;
totalBlocksAllTime: string;
totalTransactions: string;
};
} }
export type Charts = { export type Charts = {
'chart': Array<{ chart: Array<ChartsItem>;
date: string; }
value: string;
}>; export type ChartsItem ={
date: string;
value: string;
} }
...@@ -24,6 +24,10 @@ export enum QueryKeys { ...@@ -24,6 +24,10 @@ export enum QueryKeys {
address='address', address='address',
addressCounters='address-counters', addressCounters='address-counters',
addressTokenBalances='address-token-balances', addressTokenBalances='address-token-balances',
addressCoinBalanceHistory='address-coin-balance-history',
addressCoinBalanceHistoryByDay='address-coin-balance-history-by-day',
addressTxs='addressTxs', addressTxs='addressTxs',
addressTokenTransfers='addressTokenTransfers', 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 { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll'; 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 { QueryKeys } from 'types/client/queries';
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 ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
...@@ -12,15 +16,7 @@ import TxsContent from 'ui/txs/TxsContent'; ...@@ -12,15 +16,7 @@ import TxsContent from 'ui/txs/TxsContent';
import AddressTxsFilter from './AddressTxsFilter'; import AddressTxsFilter from './AddressTxsFilter';
const FILTER_VALUES = [ 'from', 'to' ] as const; const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
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 SCROLL_ELEM = 'address-txs'; const SCROLL_ELEM = 'address-txs';
const SCROLL_OFFSET = -100; const SCROLL_OFFSET = -100;
...@@ -30,11 +26,12 @@ const AddressTxs = () => { ...@@ -30,11 +26,12 @@ const AddressTxs = () => {
const isMobile = useIsMobile(); 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({ const addressTxsQuery = useQueryWithPages({
apiPath: `/node-api/addresses/${ router.query.id }/transactions`, apiPath: `/node-api/addresses/${ router.query.id }/transactions`,
queryName: QueryKeys.addressTxs, queryName: QueryKeys.addressTxs,
queryIds: castArray(router.query.id),
filters: { filter: filterValue }, filters: { filter: filterValue },
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET }, scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET },
}); });
......
...@@ -8,11 +8,13 @@ import { ...@@ -8,11 +8,13 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import FilterButton from 'ui/shared/FilterButton'; import FilterButton from 'ui/shared/FilterButton';
interface Props { interface Props {
isActive: boolean; isActive: boolean;
defaultFilter: 'from' | 'to' | undefined; defaultFilter: AddressFromToFilter;
onFilterChange: (nextValue: string | Array<string>) => void; 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'; ...@@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react';
import { test as base, expect, devices } from '@playwright/experimental-ct-react'; import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as coinBalanceMock from 'mocks/address/coinBalanceHistory';
import * as tokenBalanceMock from 'mocks/address/tokenBalance'; import * as tokenBalanceMock from 'mocks/address/tokenBalance';
import * as socketServer from 'playwright/fixtures/socketServer'; import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
...@@ -184,9 +185,7 @@ test.describe('socket', () => { ...@@ -184,9 +185,7 @@ test.describe('socket', () => {
const socket = await createSocket(); const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:1'); const channel = await socketServer.joinChannel(socket, 'addresses:1');
socketServer.sendMessage(socket, channel, 'coin_balance', { socketServer.sendMessage(socket, channel, 'coin_balance', {
coin_balance: { coin_balance: coinBalanceMock.base,
block_number: 1,
},
}); });
const button = page.getByRole('button', { name: /select/i }); const button = page.getByRole('button', { name: /select/i });
......
...@@ -138,13 +138,13 @@ const LatestBlocksItem = ({ tx }: Props) => { ...@@ -138,13 +138,13 @@ const LatestBlocksItem = ({ tx }: Props) => {
</Address> </Address>
</Flex> </Flex>
<Flex fontSize="sm" justifyContent="end" flexDirection={{ base: 'column', lg: 'row' }}> <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">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>
<Box> <Box>
<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).dp(5).toFormat() }</Text>
</Box> </Box>
</Flex> </Flex>
</Box> </Box>
......
...@@ -15,7 +15,7 @@ interface Props { ...@@ -15,7 +15,7 @@ interface Props {
caption?: string; 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 ChainIndicatorChart = ({ data }: Props) => {
const ref = React.useRef<SVGSVGElement>(null); const ref = React.useRef<SVGSVGElement>(null);
......
...@@ -8,7 +8,11 @@ import { QueryKeys } from 'types/client/queries'; ...@@ -8,7 +8,11 @@ import { QueryKeys } from 'types/client/queries';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types'; import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useFetch from 'lib/hooks/useFetch'; 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 AddressDetails from 'ui/address/AddressDetails';
import AddressInternalTxs from 'ui/address/AddressInternalTxs';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs'; import AddressTxs from 'ui/address/AddressTxs';
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';
...@@ -34,10 +38,13 @@ const AddressPageContent = () => { ...@@ -34,10 +38,13 @@ const AddressPageContent = () => {
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
{ id: 'txs', title: 'Transactions', component: <AddressTxs/> }, { 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: 'tokens', title: 'Tokens', component: null },
{ id: 'internal_txn', title: 'Internal txn', component: null }, { id: 'internal_txn', title: 'Internal txn', component: <AddressInternalTxs/> },
{ id: 'coin_balance_history', title: 'Coin balance history', component: null }, { id: 'coin_balance_history', title: 'Coin balance history', component: <AddressCoinBalance addressQuery={ addressQuery }/> },
// temporary show this tab in all address
// later api will return info about available tabs
{ id: 'blocks_validated', title: 'Blocks validated', component: <AddressBlocksValidated addressQuery={ addressQuery }/> },
]; ];
return ( return (
......
...@@ -2,7 +2,7 @@ import { Box, Icon, Link } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Box, Icon, Link } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react'; 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 config from 'configs/app/config';
import PlusIcon from 'icons/plus.svg'; import PlusIcon from 'icons/plus.svg';
......
...@@ -43,5 +43,5 @@ test('default view -@default +@desktop-xl +@mobile +@dark-mode', async({ mount, ...@@ -43,5 +43,5 @@ test('default view -@default +@desktop-xl +@mobile +@dark-mode', async({ mount,
await page.evaluate(insertAdText); 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'; ...@@ -2,7 +2,7 @@ import { Box, Center, useColorMode } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useEffect, useRef, useState } from 'react'; 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 type { AppItemOverview } from 'types/client/apps';
import { QueryKeys } from 'types/client/queries'; import { QueryKeys } from 'types/client/queries';
......
...@@ -40,19 +40,6 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled }: Props) => ...@@ -40,19 +40,6 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled }: Props) =>
const scrollDirection = useScrollDirection(); const scrollDirection = useScrollDirection();
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(tabs.length + 1); 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 isMobile = useIsMobile();
const { tabsCut, tabsList, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabs, isMobile); const { tabsCut, tabsList, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabs, isMobile);
const isSticky = useIsSticky(listRef, 5, stickyEnabled); const isSticky = useIsSticky(listRef, 5, stickyEnabled);
...@@ -68,6 +55,38 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled }: Props) => ...@@ -68,6 +55,38 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled }: Props) =>
); );
}, [ tabs, router ]); }, [ 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 ( return (
<Tabs <Tabs
variant="soft-rounded" variant="soft-rounded"
......
...@@ -51,19 +51,20 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole ...@@ -51,19 +51,20 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
}, [ tabs, disabled ]); }, [ tabs, disabled ]);
React.useEffect(() => { 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 // update refs only when disabled prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ disabled ]); }, [ disabled ]);
React.useEffect(() => { React.useEffect(() => {
if (tabsRefs.length > 0) { if (tabsRefs.length > 0 && !disabled) {
setTabsCut(calculateCut()); setTabsCut(calculateCut());
} }
}, [ calculateCut, tabsRefs ]); }, [ calculateCut, disabled, tabsRefs ]);
React.useEffect(() => { React.useEffect(() => {
if (tabsRefs.length === 0) { if (tabsRefs.length === 0 || disabled) {
return; return;
} }
...@@ -76,7 +77,7 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole ...@@ -76,7 +77,7 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
return function cleanup() { return function cleanup() {
resizeObserver.unobserve(document.body); resizeObserver.unobserve(document.body);
}; };
}, [ calculateCut, tabsRefs.length ]); }, [ calculateCut, disabled, tabsRefs.length ]);
return React.useMemo(() => { return React.useMemo(() => {
return { 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 { Hide, Show, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; 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 { TokenType } from 'types/api/tokenInfo';
import type { TokenTransferFilters } from 'types/api/tokenTransfer';
import type { QueryKeys } from 'types/client/queries'; import type { QueryKeys } from 'types/client/queries';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useQueryWithPages from 'lib/hooks/useQueryWithPages'; import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult'; import EmptySearchResult from 'ui/apps/EmptySearchResult';
...@@ -17,11 +24,21 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList'; ...@@ -17,11 +24,21 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferSkeletonMobile from 'ui/shared/TokenTransfer/TokenTransferSkeletonMobile'; import TokenTransferSkeletonMobile from 'ui/shared/TokenTransfer/TokenTransferSkeletonMobile';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable'; 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 { interface Props {
isLoading?: boolean; isLoading?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
path: string; path: string;
queryName: QueryKeys.txTokenTransfers; queryName: QueryKeys.txTokenTransfers | QueryKeys.addressTokenTransfers;
queryIds?: Array<string>; queryIds?: Array<string>;
baseAddress?: string; baseAddress?: string;
showTxInfo?: boolean; showTxInfo?: boolean;
...@@ -29,20 +46,33 @@ interface Props { ...@@ -29,20 +46,33 @@ interface Props {
} }
const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryIds, path, baseAddress, showTxInfo = true }: Props) => { const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryIds, path, baseAddress, showTxInfo = true }: Props) => {
const [ filters, setFilters ] = React.useState<Array<TokenType>>([]); const router = useRouter();
const { isError, isLoading, data, pagination } = useQueryWithPages({ 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, apiPath: path,
queryName, queryName,
queryIds, queryIds,
options: { enabled: !isDisabled }, 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>) => { const handleTypeFilterChange = React.useCallback((nextValue: Array<TokenType>) => {
setFilters(nextValue); 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 = (() => { const content = (() => {
if (isLoading || isLoadingProp) { if (isLoading || isLoadingProp) {
...@@ -65,7 +95,7 @@ const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryI ...@@ -65,7 +95,7 @@ const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryI
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
if (!data.items?.length && filters.length === 0) { if (!data.items?.length && !numActiveFilters) {
return <Text as="span">There are no token transfers</Text>; return <Text as="span">There are no token transfers</Text>;
} }
...@@ -87,15 +117,22 @@ const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryI ...@@ -87,15 +117,22 @@ const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryI
})(); })();
return ( return (
<> <Element name={ SCROLL_ELEM }>
{ !isActionBarHidden && ( { !isActionBarHidden && (
<ActionBar mt={ -6 }> <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 }/> <Pagination ml="auto" { ...pagination }/>
</ActionBar> </ActionBar>
) } ) }
{ content } { 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 React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/tokenInfo';
import FilterButton from 'ui/shared/FilterButton'; import FilterButton from 'ui/shared/FilterButton';
...@@ -9,13 +23,25 @@ import { TOKEN_TYPE } from './helpers'; ...@@ -9,13 +23,25 @@ import { TOKEN_TYPE } from './helpers';
interface Props { interface Props {
appliedFiltersNum?: number; appliedFiltersNum?: number;
defaultFilters: Array<TokenType>; defaultTypeFilters: Array<TokenType>;
onFilterChange: (nextValue: Array<TokenType>) => void; 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 { isOpen, onToggle, onClose } = useDisclosure();
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return ( return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger> <PopoverTrigger>
...@@ -27,8 +53,27 @@ const TokenTransfer = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Pr ...@@ -27,8 +53,27 @@ const TokenTransfer = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Pr
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w="200px"> <PopoverContent w="200px">
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 5 }> <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> <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>) } { TOKEN_TYPE.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup> </CheckboxGroup>
</PopoverBody> </PopoverBody>
...@@ -37,4 +82,4 @@ const TokenTransfer = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Pr ...@@ -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'> { ...@@ -14,7 +14,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl, ...props }: Props) => { const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null); 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); const textColor = useToken('colors', textColorToken);
React.useEffect(() => { React.useEffect(() => {
......
...@@ -13,7 +13,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> { ...@@ -13,7 +13,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => { const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null); 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); const strokeColor = useToken('colors', strokeColorToken);
React.useEffect(() => { React.useEffect(() => {
......
import { Box, Grid, Heading, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Text, useColorModeValue, VisuallyHidden } from '@chakra-ui/react'; import { Box, Grid, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Text, Tooltip, useColorModeValue, VisuallyHidden } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { Charts } from 'types/api/stats'; import type { TimeChartItem } from './types';
import { QueryKeys } from 'types/client/queries';
import type { StatsIntervalIds } from 'types/client/stats';
import repeatArrow from 'icons/repeat_arrow.svg'; import repeatArrow from 'icons/repeat_arrow.svg';
import dotsIcon from 'icons/vertical_dots.svg'; import dotsIcon from 'icons/vertical_dots.svg';
import useFetch from 'lib/hooks/useFetch';
import ChartWidgetGraph from './ChartWidgetGraph'; import ChartWidgetGraph from './ChartWidgetGraph';
import ChartWidgetSkeleton from './ChartWidgetSkeleton'; import ChartWidgetSkeleton from './ChartWidgetSkeleton';
import { STATS_INTERVALS } from './constants';
import FullscreenChartModal from './FullscreenChartModal'; import FullscreenChartModal from './FullscreenChartModal';
type Props = { type Props = {
id: string; items?: Array<TimeChartItem>;
title: string; title: string;
description: string; description: string;
interval: StatsIntervalIds; isLoading: boolean;
} }
function formatDate(date: Date) { const ChartWidget = ({ items, title, description, isLoading }: Props) => {
return date.toISOString().substring(0, 10);
}
const ChartWidget = ({ id, title, description, interval }: Props) => {
const fetch = useFetch();
const selectedInterval = STATS_INTERVALS[interval];
const [ isFullscreen, setIsFullscreen ] = useState(false); const [ isFullscreen, setIsFullscreen ] = useState(false);
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); 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 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(() => { const handleZoom = useCallback(() => {
setIsZoomResetInitial(false); setIsZoomResetInitial(false);
}, []); }, []);
...@@ -75,16 +51,11 @@ const ChartWidget = ({ id, title, description, interval }: Props) => { ...@@ -75,16 +51,11 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
return <ChartWidgetSkeleton/>; return <ChartWidgetSkeleton/>;
} }
if (data) { if (items) {
const items = data.chart
.map((item) => {
return { date: new Date(item.date), value: Number(item.value) };
});
return ( return (
<> <>
<Box <Box
padding={{ base: 3, md: 4 }} padding={{ base: 3, lg: 4 }}
borderRadius="md" borderRadius="md"
border="1px" border="1px"
borderColor={ borderColor } borderColor={ borderColor }
...@@ -93,12 +64,15 @@ const ChartWidget = ({ id, title, description, interval }: Props) => { ...@@ -93,12 +64,15 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
gridTemplateColumns="auto auto 36px" gridTemplateColumns="auto auto 36px"
gridColumnGap={ 2 } gridColumnGap={ 2 }
> >
<Heading <Text
mb={ 1 } fontWeight={ 600 }
size={{ base: 'xs', md: 'sm' }} fontSize="md"
lineHeight={ 6 }
as="p"
size={{ base: 'xs', lg: 'sm' }}
> >
{ title } { title }
</Heading> </Text>
<Text <Text
mb={ 1 } mb={ 1 }
...@@ -110,22 +84,23 @@ const ChartWidget = ({ id, title, description, interval }: Props) => { ...@@ -110,22 +84,23 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
{ description } { description }
</Text> </Text>
<IconButton <Tooltip label="Reset zoom">
hidden={ isZoomResetInitial } <IconButton
aria-label="Reset zoom" hidden={ isZoomResetInitial }
title="Reset zoom" aria-label="Reset zoom"
colorScheme="blue" colorScheme="blue"
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
gridColumn={ 2 } gridColumn={ 2 }
justifySelf="end" justifySelf="end"
alignSelf="top" alignSelf="top"
gridRow="1/3" gridRow="1/3"
size="sm" size="sm"
variant="ghost" variant="outline"
onClick={ handleZoomResetClick } onClick={ handleZoomResetClick }
icon={ <Icon as={ repeatArrow } w={ 4 } h={ 4 } color="blue.700"/> } icon={ <Icon as={ repeatArrow } w={ 4 } h={ 4 }/> }
/> />
</Tooltip>
<Menu> <Menu>
<MenuButton <MenuButton
...@@ -134,8 +109,9 @@ const ChartWidget = ({ id, title, description, interval }: Props) => { ...@@ -134,8 +109,9 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
justifySelf="end" justifySelf="end"
w="36px" w="36px"
h="32px" h="32px"
icon={ <Icon as={ dotsIcon } w={ 4 } h={ 4 } color={ menuButtonColor }/> } icon={ <Icon as={ dotsIcon } w={ 4 } h={ 4 }/> }
colorScheme="transparent" colorScheme="gray"
variant="ghost"
as={ IconButton } as={ IconButton }
> >
<VisuallyHidden> <VisuallyHidden>
...@@ -160,6 +136,7 @@ const ChartWidget = ({ id, title, description, interval }: Props) => { ...@@ -160,6 +136,7 @@ const ChartWidget = ({ id, title, description, interval }: Props) => {
isOpen={ isFullscreen } isOpen={ isFullscreen }
items={ items } items={ items }
title={ title } title={ title }
description={ description }
onClose={ clearFullscreenChart } onClose={ clearFullscreenChart }
/> />
</> </>
......
...@@ -15,20 +15,21 @@ import useChartSize from 'ui/shared/chart/useChartSize'; ...@@ -15,20 +15,21 @@ import useChartSize from 'ui/shared/chart/useChartSize';
import useTimeChartController from 'ui/shared/chart/useTimeChartController'; import useTimeChartController from 'ui/shared/chart/useTimeChartController';
interface Props { interface Props {
isEnlarged?: boolean;
title: string; title: string;
items: Array<TimeChartItem>; items: Array<TimeChartItem>;
onZoom: () => void; onZoom: () => void;
isZoomResetInitial: boolean; 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 isMobile = useIsMobile();
const ref = React.useRef<SVGSVGElement>(null); const ref = React.useRef<SVGSVGElement>(null);
const color = useToken('colors', 'blue.200'); const color = useToken('colors', 'blue.200');
const overlayRef = React.useRef<SVGRectElement>(null); 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 [ range, setRange ] = React.useState<[ number, number ]>([ 0, Infinity ]);
const { innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN); const { innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN);
...@@ -56,11 +57,11 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) = ...@@ -56,11 +57,11 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) =
return ( return (
<svg width="100%" height="100%" ref={ ref } cursor="pointer" id={ chartId }> <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 <ChartGridLine
type="horizontal" type="horizontal"
scale={ yScale } scale={ yScale }
ticks={ 3 } ticks={ isEnlarged ? 6 : 3 }
size={ innerWidth } size={ innerWidth }
disableAnimation disableAnimation
/> />
...@@ -78,28 +79,28 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) = ...@@ -78,28 +79,28 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) =
xScale={ xScale } xScale={ xScale }
yScale={ yScale } yScale={ yScale }
stroke={ color } stroke={ color }
animation="left" animation="none"
strokeWidth={ 3 } strokeWidth={ isMobile ? 1 : 2 }
/> />
<ChartAxis <ChartAxis
type="left" type="left"
scale={ yScale } scale={ yScale }
ticks={ 5 } ticks={ isEnlarged ? 6 : 3 }
tickFormat={ yTickFormat } tickFormat={ yTickFormat }
disableAnimation disableAnimation
/> />
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }> <ChartAxis
<ChartAxis type="bottom"
type="bottom" scale={ xScale }
scale={ xScale } transform={ `translate(0, ${ innerHeight })` }
transform={ `translate(0, ${ innerHeight })` } ticks={ isMobile ? 1 : 4 }
ticks={ isMobile ? 1 : 3 } anchorEl={ overlayRef.current }
anchorEl={ overlayRef.current } disableAnimation
disableAnimation />
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartTooltip <ChartTooltip
chartId={ chartId } chartId={ chartId }
anchorEl={ overlayRef.current } anchorEl={ overlayRef.current }
......
...@@ -5,7 +5,7 @@ const ChartWidgetSkeleton = () => { ...@@ -5,7 +5,7 @@ const ChartWidgetSkeleton = () => {
return ( return (
<Box <Box
height="235px" height="235px"
paddingY={{ base: 3, md: 4 }} paddingY={{ base: 3, lg: 4 }}
> >
<Skeleton w="75%" h="24px" mb={ 1 }/> <Skeleton w="75%" h="24px" mb={ 1 }/>
<Skeleton w="50%" h="18px" mb={ 5 }/> <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 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'; import ChartWidgetGraph from './ChartWidgetGraph';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
title: string; title: string;
description: string;
items: Array<TimeChartItem>; items: Array<TimeChartItem>;
onClose: () => void; onClose: () => void;
} }
...@@ -15,6 +18,7 @@ type Props = { ...@@ -15,6 +18,7 @@ type Props = {
const FullscreenChartModal = ({ const FullscreenChartModal = ({
isOpen, isOpen,
title, title,
description,
items, items,
onClose, onClose,
}: Props) => { }: Props) => {
...@@ -39,44 +43,53 @@ const FullscreenChartModal = ({ ...@@ -39,44 +43,53 @@ const FullscreenChartModal = ({
<ModalContent> <ModalContent>
<ModalHeader> <Box
<Flex mb={ 1 }
alignItems="center" >
<Grid
gridColumnGap={ 2 }
> >
<Heading <Heading
as="h2" mb={ 1 }
gridColumn={ 2 } size={{ base: 'xs', sm: 'md' }}
fontSize={{ base: '2xl', sm: '3xl' }}
fontWeight="medium"
lineHeight={ 1 }
color="blue.600"
> >
{ title } { title }
</Heading> </Heading>
<Text
gridColumn={ 1 }
as="p"
variant="secondary"
fontSize="xs"
>
{ description }
</Text>
{ !isZoomResetInitial && ( { !isZoomResetInitial && (
<Button <Button
ml="auto" leftIcon={ <Icon as={ repeatArrow } w={ 4 } h={ 4 }/> }
colorScheme="blue"
gridColumn={ 2 } gridColumn={ 2 }
justifySelf="end" justifySelf="end"
alignSelf="top" alignSelf="top"
gridRow="1/3" gridRow="1/3"
size="md" size="sm"
variant="outline" variant="outline"
onClick={ handleZoomResetClick } onClick={ handleZoomResetClick }
> >
Reset zoom Reset zoom
</Button> </Button>
) } ) }
</Flex> </Grid>
</ModalHeader> </Box>
<ModalCloseButton/> <ModalCloseButton/>
<ModalBody <ModalBody
h="75%" h="100%"
> >
<ChartWidgetGraph <ChartWidgetGraph
isEnlarged
items={ items } items={ items }
onZoom={ handleZoom } onZoom={ handleZoom }
isZoomResetInitial={ isZoomResetInitial } isZoomResetInitial={ isZoomResetInitial }
......
...@@ -16,18 +16,26 @@ export default function useChartSize(svgEl: SVGSVGElement | null, margin?: Chart ...@@ -16,18 +16,26 @@ export default function useChartSize(svgEl: SVGSVGElement | null, margin?: Chart
}, [ calculateRect ]); }, [ calculateRect ]);
React.useEffect(() => { React.useEffect(() => {
const content = window.document.querySelector('main');
if (!content) {
return;
}
let timeoutId: number; let timeoutId: number;
const resizeHandler = _debounce(() => { const resizeHandler = _debounce(() => {
setRect({ width: 0, height: 0 }); setRect({ width: 0, height: 0 });
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
setRect(calculateRect()); setRect(calculateRect());
}, 0); }, 0);
}, 100); }, 200);
const resizeObserver = new ResizeObserver(resizeHandler); const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(content);
resizeObserver.observe(window.document.body);
resizeObserver.observe(document.body);
return function cleanup() { return function cleanup() {
resizeObserver.unobserve(document.body); resizeObserver.unobserve(content);
resizeObserver.unobserve(window.document.body);
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [ calculateRect ]); }, [ calculateRect ]);
......
...@@ -37,7 +37,8 @@ export default function useTimeChartController({ data, width, height }: Props) { ...@@ -37,7 +37,8 @@ export default function useTimeChartController({ data, width, height }: Props) {
); );
const yScale = useMemo(() => { const yScale = useMemo(() => {
const indention = (yMax - yMin) * 0.3; const indention = (yMax - yMin) * 0.15;
return d3.scaleLinear() return d3.scaleLinear()
.domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ]) .domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ])
.range([ height, 0 ]); .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 = () => { ...@@ -29,6 +29,7 @@ const Burger = () => {
boxSize={ 6 } boxSize={ 6 }
display="block" display="block"
color={ iconColor } color={ iconColor }
aria-label="Menu button"
/> />
</Box> </Box>
<Drawer <Drawer
......
...@@ -89,6 +89,7 @@ const ColorModeToggler = forwardRef<ColorModeTogglerProps, 'input'>((props, ref) ...@@ -89,6 +89,7 @@ const ColorModeToggler = forwardRef<ColorModeTogglerProps, 'input'>((props, ref)
<chakra.div <chakra.div
{ ...getCheckboxProps() } { ...getCheckboxProps() }
__css={ trackStyles } __css={ trackStyles }
aria-label="Toggle color mode"
> >
<Icon <Icon
boxSize={ 4 } 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 } });
});
...@@ -33,6 +33,7 @@ const NavLink = ({ text, url, icon, isCollapsed, isActive, px, isNewUi }: Props) ...@@ -33,6 +33,7 @@ const NavLink = ({ text, url, icon, isCollapsed, isActive, px, isNewUi }: Props)
_hover={{ color: isActive ? colors.text.active : colors.text.hover }} _hover={{ color: isActive ? colors.text.active : colors.text.hover }}
borderRadius="base" borderRadius="base"
whiteSpace="nowrap" whiteSpace="nowrap"
aria-label={ `${ text } link` }
{ ...getDefaultTransitionProps({ transitionProperty: 'width, padding' }) } { ...getDefaultTransitionProps({ transitionProperty: 'width, padding' }) }
> >
<Tooltip <Tooltip
...@@ -44,13 +45,16 @@ const NavLink = ({ text, url, icon, isCollapsed, isActive, px, isNewUi }: Props) ...@@ -44,13 +45,16 @@ const NavLink = ({ text, url, icon, isCollapsed, isActive, px, isNewUi }: Props)
gutter={ 20 } gutter={ 20 }
color={ isActive ? colors.text.active : colors.text.hover } color={ isActive ? colors.text.active : colors.text.hover }
> >
<HStack spacing={ 3 }> <HStack spacing={ 3 } overflow="hidden">
<Icon as={ icon } boxSize="30px"/> <Icon as={ icon } boxSize="30px"/>
<Text <Text
variant="inherit" variant="inherit"
fontSize="sm" fontSize="sm"
lineHeight="20px" lineHeight="20px"
display={{ base: 'block', lg: isExpanded ? 'block' : 'none', xl: isCollapsed ? 'none' : 'block' }} opacity={{ base: '1', lg: isExpanded ? '1' : '0', xl: isCollapsed ? '0' : '1' }}
transitionProperty="opacity"
transitionDuration="normal"
transitionTimingFunction="ease"
> >
{ text } { text }
</Text> </Text>
......
import { Box, Flex } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as cookies from 'lib/cookies';
import authFixture from 'playwright/fixtures/auth';
import TestApp from 'playwright/TestApp';
import NavigationDesktop from './NavigationDesktop';
const hooksConfig = {
router: {
route: '/blocks',
query: { id: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859' },
},
};
test('no auth +@desktop-xl +@dark-mode-xl', async({ mount }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test.describe('auth', () => {
const extendedTest = test.extend({
context: ({ context }, use) => {
authFixture(context);
use(context);
},
});
extendedTest('+@desktop-xl +@dark-mode-xl', async({ mount }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
});
test('with tooltips +@desktop-xl -@default', async({ mount, page }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
await page.locator('svg[aria-label="Expand/Collapse menu"]').click();
await page.locator('a[aria-label="Tokens link"]').hover();
await expect(component).toHaveScreenshot();
});
test.describe('cookie set to false', () => {
const extendedTest = test.extend({
context: ({ context }, use) => {
context.addCookies([ { name: cookies.NAMES.NAV_BAR_COLLAPSED, value: 'false', domain: 'localhost', path: '/' } ]);
use(context);
},
});
extendedTest('navbar is opened +@desktop-xl', async({ mount }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
const networkMenu = component.locator('button[aria-label="Network menu"]');
expect(await networkMenu.isVisible()).toBe(true);
});
});
test.describe('cookie set to true', () => {
const extendedTest = test.extend({
context: ({ context }, use) => {
context.addCookies([ { name: cookies.NAMES.NAV_BAR_COLLAPSED, value: 'true', domain: 'localhost', path: '/' } ]);
use(context);
},
});
extendedTest('navbar is collapsed', async({ mount }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
const networkMenu = component.locator('button[aria-label="Network menu"]');
expect(await networkMenu.isVisible()).toBe(false);
});
});
...@@ -77,13 +77,13 @@ const NavigationDesktop = () => { ...@@ -77,13 +77,13 @@ const NavigationDesktop = () => {
<NetworkMenu isCollapsed={ isCollapsed }/> <NetworkMenu isCollapsed={ isCollapsed }/>
</Box> </Box>
<Box as="nav" mt={ 8 }> <Box as="nav" mt={ 8 }>
<VStack as="ul" spacing="2" alignItems="flex-start" overflow="hidden"> <VStack as="ul" spacing="1" alignItems="flex-start">
{ mainNavItems.map((item) => <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>) } { mainNavItems.map((item) => <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>) }
</VStack> </VStack>
</Box> </Box>
{ hasAccount && ( { hasAccount && (
<Box as="nav" mt={ 12 }> <Box as="nav" mt={ 8 }>
<VStack as="ul" spacing="2" alignItems="flex-start" overflow="hidden"> <VStack as="ul" spacing="1" alignItems="flex-start">
{ accountNavItems.map((item) => <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>) } { accountNavItems.map((item) => <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>) }
</VStack> </VStack>
</Box> </Box>
...@@ -105,6 +105,7 @@ const NavigationDesktop = () => { ...@@ -105,6 +105,7 @@ const NavigationDesktop = () => {
left={{ lg: isExpanded ? '216px' : '80px', xl: isCollapsed ? '80px' : '216px' }} left={{ lg: isExpanded ? '216px' : '80px', xl: isCollapsed ? '80px' : '216px' }}
cursor="pointer" cursor="pointer"
onClick={ handleTogglerClick } onClick={ handleTogglerClick }
aria-label="Expand/Collapse menu"
/> />
</Flex> </Flex>
); );
......
...@@ -16,13 +16,13 @@ const NavigationMobile = () => { ...@@ -16,13 +16,13 @@ const NavigationMobile = () => {
return ( return (
<> <>
<Box as="nav" mt={ 6 }> <Box as="nav" mt={ 6 }>
<VStack as="ul" spacing="2" alignItems="flex-start" overflow="hidden"> <VStack as="ul" spacing="1" alignItems="flex-start">
{ mainNavItems.map((item) => <NavLink key={ item.text } { ...item }/>) } { mainNavItems.map((item) => <NavLink key={ item.text } { ...item }/>) }
</VStack> </VStack>
</Box> </Box>
{ isAuth && ( { isAuth && (
<Box as="nav" mt={ 6 }> <Box as="nav" mt={ 6 }>
<VStack as="ul" spacing="2" alignItems="flex-start" overflow="hidden"> <VStack as="ul" spacing="1" alignItems="flex-start">
{ accountNavItems.map((item) => <NavLink key={ item.text } { ...item }/>) } { accountNavItems.map((item) => <NavLink key={ item.text } { ...item }/>) }
</VStack> </VStack>
</Box> </Box>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import NetworkLogo from './NetworkLogo';
test('fallback logo +@desktop-xl +@dark-mode +@dark-mode-xl', async({ mount }) => {
const component = await mount(
<TestApp>
<NetworkLogo/>
</TestApp>,
);
await expect(component.locator('a')).toHaveScreenshot();
});
test.describe('placeholder logo', () => {
const extendedTest = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_NETWORK_TYPE', value: 'unknown' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
extendedTest('+@desktop-xl +@dark-mode +@dark-mode-xl', async({ mount }) => {
const component = await mount(
<TestApp>
<NetworkLogo/>
</TestApp>,
);
await expect(component.locator('a')).toHaveScreenshot();
});
});
test.describe('custom logo', () => {
const LOGO_URL = 'https://example.com/my-logo.png';
const SMALL_LOGO_URL = 'https://example.com/my-logo-short.png';
const extendedTest = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_NETWORK_LOGO', value: LOGO_URL },
{ name: 'NEXT_PUBLIC_NETWORK_SMALL_LOGO', value: SMALL_LOGO_URL },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
extendedTest('+@desktop-xl +@dark-mode +@dark-mode-xl', async({ mount, page }) => {
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/giant_duck_long.jpg',
});
});
await page.route(SMALL_LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
const component = await mount(
<TestApp>
<NetworkLogo/>
</TestApp>,
);
await expect(component.locator('a')).toHaveScreenshot();
});
});
...@@ -65,7 +65,7 @@ const NetworkLogo = ({ isCollapsed, onClick }: Props) => { ...@@ -65,7 +65,7 @@ const NetworkLogo = ({ isCollapsed, onClick }: Props) => {
src={ logo } src={ logo }
display={{ base: 'block', lg: isCollapsed === false ? 'block' : 'none', xl: isCollapsed ? 'none' : 'block' }} display={{ base: 'block', lg: isCollapsed === false ? 'block' : 'none', xl: isCollapsed ? 'none' : 'block' }}
alt={ `${ appConfig.network.name } network logo` } alt={ `${ appConfig.network.name } network logo` }
fallback={ isLogoError || !logo ? fallbackSmallLogo : undefined } fallback={ isLogoError || !logo ? fallbackLogo : undefined }
onError={ handleLogoError } onError={ handleLogoError }
/> />
{ /* small logo */ } { /* small logo */ }
...@@ -75,7 +75,7 @@ const NetworkLogo = ({ isCollapsed, onClick }: Props) => { ...@@ -75,7 +75,7 @@ const NetworkLogo = ({ isCollapsed, onClick }: Props) => {
src={ smallLogo } src={ smallLogo }
display={{ base: 'none', lg: isCollapsed === false ? 'none' : 'block', xl: isCollapsed ? 'block' : 'none' }} display={{ base: 'none', lg: isCollapsed === false ? 'none' : 'block', xl: isCollapsed ? 'block' : 'none' }}
alt={ `${ appConfig.network.name } network logo` } alt={ `${ appConfig.network.name } network logo` }
fallback={ isSmallLogoError || !smallLogo ? fallbackLogo : undefined } fallback={ isSmallLogoError || !smallLogo ? fallbackSmallLogo : undefined }
onError={ handleSmallLogoError } onError={ handleSmallLogoError }
/> />
</> </>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { FEATURED_NETWORKS_MOCK } from 'mocks/config/network';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import NetworkMenu from './NetworkMenu';
const extendedTest = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_FEATURED_NETWORKS', value: FEATURED_NETWORKS_MOCK },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
extendedTest.use({ viewport: { width: 1600, height: 1000 } });
extendedTest('base view +@dark-mode', async({ mount, page }) => {
const LOGO_URL = 'https://example.com/my-logo.png';
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
const component = await mount(
<TestApp>
<NetworkMenu/>
</TestApp>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 36, height: 36 } });
await component.locator('button[aria-label="Network menu"]').hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 36, height: 36 } });
await component.locator('button[aria-label="Network menu"]').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 450, height: 550 } });
await component.getByText(/optimism/i).hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 450, height: 550 } });
});
import { Popover, PopoverTrigger, Box } from '@chakra-ui/react'; import { Popover, PopoverTrigger } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import NetworkMenuButton from './NetworkMenuButton'; import NetworkMenuButton from './NetworkMenuButton';
...@@ -9,17 +9,16 @@ interface Props { ...@@ -9,17 +9,16 @@ interface Props {
const NetworkMenu = ({ isCollapsed }: Props) => { const NetworkMenu = ({ isCollapsed }: Props) => {
return ( return (
<Popover openDelay={ 300 } placement="right-start" gutter={ 22 } isLazy> <Popover openDelay={ 300 } placement="right-start" gutter={ 8 } isLazy>
{ ({ isOpen }) => ( { ({ isOpen }) => (
<> <>
<PopoverTrigger> <PopoverTrigger>
<Box <NetworkMenuButton
marginLeft="auto" marginLeft="auto"
overflow="hidden" overflow="hidden"
width={{ base: 'auto', lg: isCollapsed === false ? 'auto' : '0px', xl: isCollapsed ? '0px' : 'auto' }} width={{ base: 'auto', lg: isCollapsed === false ? 'auto' : '0px', xl: isCollapsed ? '0px' : 'auto' }}
> isActive={ isOpen }
<NetworkMenuButton isActive={ isOpen }/> />
</Box>
</PopoverTrigger> </PopoverTrigger>
<NetworkMenuContentDesktop/> <NetworkMenuContentDesktop/>
</> </>
......
import { Icon, useColorModeValue, Button } from '@chakra-ui/react'; import { Icon, useColorModeValue, Button, forwardRef, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import networksIcon from 'icons/networks.svg'; import networksIcon from 'icons/networks.svg';
...@@ -8,15 +8,17 @@ interface Props { ...@@ -8,15 +8,17 @@ interface Props {
isMobile?: boolean; isMobile?: boolean;
isActive?: boolean; isActive?: boolean;
onClick?: () => void; onClick?: () => void;
className?: string;
} }
const NetworkMenuButton = ({ isMobile, isActive, onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => { const NetworkMenuButton = ({ isMobile, isActive, onClick, className }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const defaultIconColor = useColorModeValue('gray.600', 'gray.400'); const defaultIconColor = useColorModeValue('gray.600', 'gray.400');
const bgColorMobile = useColorModeValue('blue.50', 'gray.800'); const bgColorMobile = useColorModeValue('blue.50', 'gray.800');
const iconColorMobile = useColorModeValue('blue.700', 'blue.50'); const iconColorMobile = useColorModeValue('blue.700', 'blue.50');
return ( return (
<Button <Button
className={ className }
variant="unstyled" variant="unstyled"
display="inline-flex" display="inline-flex"
alignSelf="stretch" alignSelf="stretch"
...@@ -43,4 +45,4 @@ const NetworkMenuButton = ({ isMobile, isActive, onClick }: Props, ref: React.Fo ...@@ -43,4 +45,4 @@ const NetworkMenuButton = ({ isMobile, isActive, onClick }: Props, ref: React.Fo
); );
}; };
export default React.forwardRef(NetworkMenuButton); export default chakra(forwardRef(NetworkMenuButton));
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as profileMock from 'mocks/user/profile';
import authFixture from 'playwright/fixtures/auth';
import TestApp from 'playwright/TestApp';
import ProfileMenuDesktop from './ProfileMenuDesktop';
test('no auth', async({ mount, page }) => {
const hooksConfig = {
router: {
asPath: '/',
},
};
const component = await mount(
<TestApp>
<ProfileMenuDesktop/>
</TestApp>,
{ hooksConfig },
);
await component.locator('.identicon').click();
expect(page.url()).toBe('http://localhost:3100/auth/auth0?path=%2F');
});
test.describe('auth', () => {
const extendedTest = test.extend({
context: ({ context }, use) => {
authFixture(context);
use(context);
},
});
extendedTest('+@dark-mode', async({ mount, page }) => {
await page.route('/node-api/account/profile', (route) => route.fulfill({
status: 200,
body: JSON.stringify(profileMock.base),
}));
await page.route(profileMock.base.avatar, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
const component = await mount(
<TestApp>
<ProfileMenuDesktop/>
</TestApp>,
);
await component.getByAltText(/Profile picture/i).click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 550 } });
});
});
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as profileMock from 'mocks/user/profile';
import authFixture from 'playwright/fixtures/auth';
import TestApp from 'playwright/TestApp';
import ProfileMenuMobile from './ProfileMenuMobile';
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('no auth', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ProfileMenuMobile/>
</TestApp>,
);
await component.locator('.identicon').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 }) => {
await page.route('/node-api/account/profile', (route) => route.fulfill({
status: 200,
body: JSON.stringify(profileMock.base),
}));
await page.route(profileMock.base.avatar, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
const component = await mount(
<TestApp>
<ProfileMenuMobile/>
</TestApp>,
);
await component.getByAltText(/Profile picture/i).click();
await expect(page).toHaveScreenshot();
await page.locator('div[aria-label="Toggle color mode"]').click();
await expect(page).toHaveScreenshot();
});
});
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { Charts } from 'types/api/stats';
import { QueryKeys } from 'types/client/queries';
import type { StatsIntervalIds } from 'types/client/stats';
import useFetch from 'lib/hooks/useFetch';
import ChartWidget from '../shared/chart/ChartWidget';
import { STATS_INTERVALS } from './constants';
type Props = {
id: string;
title: string;
description: string;
interval: StatsIntervalIds;
}
function formatDate(date: Date) {
return date.toISOString().substring(0, 10);
}
const ChartWidgetContainer = ({ id, title, description, interval }: Props) => {
const fetch = useFetch();
const selectedInterval = STATS_INTERVALS[interval];
const endDate = selectedInterval.start ? formatDate(new Date()) : undefined;
const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined;
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 items = data?.chart
.map((item) => {
return { date: new Date(item.date), value: Number(item.value) };
});
return (
<ChartWidget
items={ items }
title={ title }
description={ description }
isLoading={ isLoading }
/>
);
};
export default ChartWidgetContainer;
...@@ -6,7 +6,7 @@ import type { StatsIntervalIds, StatsSection } from 'types/client/stats'; ...@@ -6,7 +6,7 @@ import type { StatsIntervalIds, StatsSection } from 'types/client/stats';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import EmptySearchResult from '../apps/EmptySearchResult'; import EmptySearchResult from '../apps/EmptySearchResult';
import ChartWidget from './ChartWidget'; import ChartWidgetContainer from './ChartWidgetContainer';
type Props = { type Props = {
charts: Array<StatsSection>; charts: Array<StatsSection>;
...@@ -46,7 +46,7 @@ const ChartsWidgetsList = ({ charts, interval }: Props) => { ...@@ -46,7 +46,7 @@ const ChartsWidgetsList = ({ charts, interval }: Props) => {
<GridItem <GridItem
key={ chart.id } key={ chart.id }
> >
<ChartWidget <ChartWidgetContainer
id={ chart.id } id={ chart.id }
title={ chart.title } title={ chart.title }
description={ chart.description } description={ chart.description }
......
...@@ -10,7 +10,8 @@ const NumberWidget = ({ label, value }: Props) => { ...@@ -10,7 +10,8 @@ const NumberWidget = ({ label, value }: Props) => {
return ( return (
<Box <Box
bg={ useColorModeValue('blue.50', 'blue.800') } bg={ useColorModeValue('blue.50', 'blue.800') }
p={ 3 } px={ 3 }
py={{ base: 2, lg: 3 }}
borderRadius={ 12 } borderRadius={ 12 }
> >
<Text <Text
......
...@@ -10,7 +10,7 @@ import useFetch from 'lib/hooks/useFetch'; ...@@ -10,7 +10,7 @@ import useFetch from 'lib/hooks/useFetch';
import NumberWidget from './NumberWidget'; import NumberWidget from './NumberWidget';
import NumberWidgetSkeleton from './NumberWidgetSkeleton'; import NumberWidgetSkeleton from './NumberWidgetSkeleton';
const skeletonsCount = 4; const skeletonsCount = 8;
const NumberWidgetsList = () => { const NumberWidgetsList = () => {
const fetch = useFetch(); const fetch = useFetch();
...@@ -28,10 +28,28 @@ const NumberWidgetsList = () => { ...@@ -28,10 +28,28 @@ const NumberWidgetsList = () => {
{ isLoading ? [ ...Array(skeletonsCount) ] { isLoading ? [ ...Array(skeletonsCount) ]
.map((e, i) => <NumberWidgetSkeleton key={ i }/>) : .map((e, i) => <NumberWidgetSkeleton key={ i }/>) :
( (
<NumberWidget <>
label="Total blocks all time" <NumberWidget
value={ Number(data?.totalBlocksAllTime).toLocaleString() } label="Total blocks"
/> value={ Number(data?.counters.totalBlocksAllTime).toLocaleString() }
/>
<NumberWidget
label="Average block time"
value={ Number(data?.counters.averageBlockTime).toLocaleString() }
/>
<NumberWidget
label="Completed transactions"
value={ Number(data?.counters.completedTransactions).toLocaleString() }
/>
<NumberWidget
label="Total transactions"
value={ Number(data?.counters.totalTransactions).toLocaleString() }
/>
<NumberWidget
label="Total accounts"
value={ Number(data?.counters.totalAccounts).toLocaleString() }
/>
</>
) } ) }
</Grid> </Grid>
); );
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as txMock from 'mocks/txs/tx';
import TestApp from 'playwright/TestApp';
import TxsTable from './TxsTable';
test('base view +@dark-mode +@desktop-xl', async({ mount }) => {
const component = await mount(
<TestApp>
{ /* eslint-disable-next-line react/jsx-no-bind */ }
<TxsTable txs={ [ txMock.base, txMock.base ] } sort={ () => () => {} } top={ 0 } showBlockInfo showSocketInfo={ false }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
...@@ -27,8 +27,8 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, curr ...@@ -27,8 +27,8 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, curr
<TheadSticky top={ top }> <TheadSticky top={ top }>
<Tr> <Tr>
<Th width="54px"></Th> <Th width="54px"></Th>
<Th width="20%">Type</Th>
<Th width="18%">Txn hash</Th> <Th width="18%">Txn hash</Th>
<Th width="20%">Type</Th>
<Th width="15%">Method</Th> <Th width="15%">Method</Th>
{ showBlockInfo && <Th width="11%">Block</Th> } { showBlockInfo && <Th width="11%">Block</Th> }
<Th width={{ xl: '128px', base: '66px' }}>From</Th> <Th width={{ xl: '128px', base: '66px' }}>From</Th>
......
...@@ -84,13 +84,7 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress }: Props) => { ...@@ -84,13 +84,7 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress }: Props) => {
) } ) }
</Popover> </Popover>
</Td> </Td>
<Td> <Td pr={ 4 }>
<VStack alignItems="start">
<TxType types={ tx.tx_types }/>
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined }/>
</VStack>
</Td>
<Td>
<VStack alignItems="start" lineHeight="24px"> <VStack alignItems="start" lineHeight="24px">
<Address width="100%"> <Address width="100%">
<AddressLink <AddressLink
...@@ -102,6 +96,12 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress }: Props) => { ...@@ -102,6 +96,12 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress }: Props) => {
<Text color="gray.500" fontWeight="400">{ dayjs(tx.timestamp).fromNow() }</Text> <Text color="gray.500" fontWeight="400">{ dayjs(tx.timestamp).fromNow() }</Text>
</VStack> </VStack>
</Td> </Td>
<Td>
<VStack alignItems="start">
<TxType types={ tx.tx_types }/>
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined }/>
</VStack>
</Td>
<Td whiteSpace="nowrap"> <Td whiteSpace="nowrap">
{ tx.method ? ( { tx.method ? (
<TruncatedTextTooltip label={ tx.method }> <TruncatedTextTooltip label={ tx.method }>
......
...@@ -3537,6 +3537,11 @@ ...@@ -3537,6 +3537,11 @@
expect "^29.0.0" expect "^29.0.0"
pretty-format "^29.0.0" pretty-format "^29.0.0"
"@types/js-cookie@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.2.tgz#451eaeece64c6bdac8b2dde0caab23b085899e0d"
integrity sha512-6+0ekgfusHftJNYpihfkMu8BWdeHs9EOJuGcSofErjstGPfPGEu9yTu4t460lTzzAMl2cM5zngQJqPMHbbnvYA==
"@types/jsdom@^20.0.0": "@types/jsdom@^20.0.0":
version "20.0.1" version "20.0.1"
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808"
...@@ -6975,6 +6980,11 @@ joycon@^3.1.1: ...@@ -6975,6 +6980,11 @@ joycon@^3.1.1:
resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03"
integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==
js-cookie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414"
integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==
js-sdsl@^4.1.4: js-sdsl@^4.1.4:
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.2.0.tgz#278e98b7bea589b8baaf048c20aeb19eb7ad09d0" resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.2.0.tgz#278e98b7bea589b8baaf048c20aeb19eb7ad09d0"
...@@ -8999,11 +9009,6 @@ type-fest@^0.21.3: ...@@ -8999,11 +9009,6 @@ type-fest@^0.21.3:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
typescript-cookie@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/typescript-cookie/-/typescript-cookie-1.0.4.tgz#22715aa394275e967fd734fa1417e24870504985"
integrity sha512-vZo252VmoEleD/dbE9Wb2lMK63V3M/8aqFbp2Pdb4Oxq8YqqADJ7iMh8THZenFXN+uZJPE8RXkztEaHkOptH4w==
typescript@4.7.2: typescript@4.7.2:
version "4.7.2" version "4.7.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4"
......
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