Commit 9ebdd138 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into homepage-update

parents d49869eb c4a1fd36
...@@ -67,6 +67,7 @@ jobs: ...@@ -67,6 +67,7 @@ jobs:
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 env_vars: VALUES_DIR=deploy/values/review,APP_NAME=bs-stack
globalEnv: review
appNamespace: review-front-$GITHUB_HEAD_REF_SLUG appNamespace: review-front-$GITHUB_HEAD_REF_SLUG
blockscoutIngressHost: blockscout blockscoutIngressHost: blockscout
frontendIngressHost: blockscout frontendIngressHost: blockscout
......
# app config # app config
NEXT_PUBLIC_APP_HOST=blockscout.com NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3100
NEXT_PUBLIC_APP_INSTANCE=pw NEXT_PUBLIC_APP_INSTANCE=pw
NEXT_PUBLIC_APP_ENV=testing NEXT_PUBLIC_APP_ENV=testing
......
...@@ -160,14 +160,14 @@ postgres: ...@@ -160,14 +160,14 @@ postgres:
resources: resources:
limits: limits:
memory: memory:
_default: "4Gi" _default: "6Gi"
cpu: cpu:
_default: "3" _default: "4"
requests: requests:
memory: memory:
_default: "4Gi" _default: "6Gi"
cpu: cpu:
_default: "3" _default: "4"
environment: environment:
POSTGRES_USER: POSTGRES_USER:
......
...@@ -65,7 +65,7 @@ geth: ...@@ -65,7 +65,7 @@ geth:
frontend: frontend:
environment: environment:
NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS: NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS:
_default: ENC[AES256_GCM,data:1SAbzZhCs/vzdftIX0WVLtImH27NJ6SwENee4uTu2p+ZyUso3nQCLUUm,iv:apyLxt2dQ5RN33ra1Q1sAy2cyplG9FSryksQru2ghlA=,tag:PVcCNt0bz1TfQewUebV5LA==,type:str] _default: ENC[AES256_GCM,data:yShwsa6ajoFXg/6QSgEARkZRVVrwrdsR69NSmyvBH2O5EUQ0OvsWpW64,iv:K/HT6C9pYCK63LNyF3HERFc79vDS4cB0H4pINIlNhh0=,tag:X0HqeAP01diTvDOwoEP6lw==,type:str]
NEXT_PUBLIC_SENTRY_DSN: NEXT_PUBLIC_SENTRY_DSN:
_default: ENC[AES256_GCM,data:n/H2AH2n9ovn265iFbbrqeOOWS3s7FXgDv5FXJ2Dz133GuhTaqIU5psWjTraZ/Vh+dVM8M9zBLFfUanCWOz7cerq/QUoSVZjKvIvcyp6F072DGU=,iv:Co/pSR+U0vfkmWR+LDpxcQKDJl0WNbaEZihpzrRJcbc=,tag:HUiCX8WGJXWCDB59Y6iIbw==,type:str] _default: ENC[AES256_GCM,data:n/H2AH2n9ovn265iFbbrqeOOWS3s7FXgDv5FXJ2Dz133GuhTaqIU5psWjTraZ/Vh+dVM8M9zBLFfUanCWOz7cerq/QUoSVZjKvIvcyp6F072DGU=,iv:Co/pSR+U0vfkmWR+LDpxcQKDJl0WNbaEZihpzrRJcbc=,tag:HUiCX8WGJXWCDB59Y6iIbw==,type:str]
SENTRY_CSP_REPORT_URI: SENTRY_CSP_REPORT_URI:
...@@ -78,8 +78,8 @@ sops: ...@@ -78,8 +78,8 @@ sops:
azure_kv: [] azure_kv: []
hc_vault: [] hc_vault: []
age: [] age: []
lastmodified: "2022-11-16T11:55:15Z" lastmodified: "2022-11-28T16:58:46Z"
mac: ENC[AES256_GCM,data:wd7HZEGH1fJO1ufaUeBtcjUaHS21rcOoiGaQPNptEyEPj6q/60rJ1YQmGqkgi3DNVnGly5FQyYjMIIY3/YqXqTZI6MBhJp4RmpCELbqqzQbAFvbomYURmqG/umeT2+kMrSIF/PXrt4d51e1cod2+H4OY9V09VerH9L07D0nTd48=,iv:dDeTSqvmwps4oQKRVgDqmMf/uxf7Egb+jufwTKtm6F4=,tag:CqOCA4XW7d3C5D4dflIFug==,type:str] mac: ENC[AES256_GCM,data:QJvVfWWWVDk5mI66T9J8EnEyVwmJoGEsWO9Pr8vK7jyC3rhAYD2WdKYfpkbwwMKrJzcMBe7UeaOeEY6aApuMNdobeEjsJAvstXCOBzMe5H9XtAFiAY+oxf8r4ELNvQP/gIBZSja+ehSbXBcaP4DkLn4FboaBhkoE8A37W2R6/QA=,iv:FnIC6iGLEZNwRSrbF81vF6eQuyq0yQHNPRTPrx3FB+8=,tag:LRnZCwYkCh4o8lDUcG2m9A==,type:str]
pgp: pgp:
- created_at: "2022-09-14T13:42:28Z" - created_at: "2022-09-14T13:42:28Z"
enc: | enc: |
......
...@@ -3,7 +3,7 @@ global: ...@@ -3,7 +3,7 @@ global:
# enable Blockscout deploy # enable Blockscout deploy
blockscout: blockscout:
app: blockscout app: blockscout
enabled: true enabled: false
image: image:
_default: blockscout/blockscout:latest _default: blockscout/blockscout:latest
replicas: replicas:
...@@ -117,7 +117,7 @@ blockscout: ...@@ -117,7 +117,7 @@ blockscout:
_default: 'true' _default: 'true'
postgres: postgres:
enabled: true enabled: false
image: postgres:13.8 image: postgres:13.8
port: 5432 port: 5432
...@@ -145,7 +145,7 @@ postgres: ...@@ -145,7 +145,7 @@ postgres:
_default: 'trust' _default: 'trust'
# enable geth deploy # enable geth deploy
geth: geth:
enabled: true enabled: false
image: image:
_default: ethereum/client-go:stable _default: ethereum/client-go:stable
replicas: replicas:
...@@ -200,7 +200,7 @@ geth: ...@@ -200,7 +200,7 @@ geth:
enabled: true enabled: true
# enable Smart-contract-verifier deploy # enable Smart-contract-verifier deploy
scVerifier: scVerifier:
enabled: true enabled: false
image: image:
_default: ghcr.io/blockscout/smart-contract-verifier:latest _default: ghcr.io/blockscout/smart-contract-verifier:latest
replicas: replicas:
...@@ -347,19 +347,19 @@ frontend: ...@@ -347,19 +347,19 @@ frontend:
NEXT_PUBLIC_FOOTER_STAKING_LINK: NEXT_PUBLIC_FOOTER_STAKING_LINK:
_default: https://duneanalytics.com/maxaleks/xdai-staking _default: https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_NETWORK_NAME: NEXT_PUBLIC_NETWORK_NAME:
_default: Sokol _default: Ethereum
NEXT_PUBLIC_NETWORK_SHORT_NAME: NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: POA _default: Goerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME: NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME:
_default: poa _default: ethereum
NEXT_PUBLIC_NETWORK_TYPE: NEXT_PUBLIC_NETWORK_TYPE:
_default: poa_core _default: goerli
NEXT_PUBLIC_NETWORK_ID: NEXT_PUBLIC_NETWORK_ID:
_default: 77 _default: 5
NEXT_PUBLIC_NETWORK_CURRENCY_NAME: NEXT_PUBLIC_NETWORK_CURRENCY_NAME:
_default: POA Network Sokol _default: Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL: NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL:
_default: SPOA _default: ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS: NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS:
_default: 18 _default: 18
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE:
...@@ -370,6 +370,7 @@ frontend: ...@@ -370,6 +370,7 @@ frontend:
_default: "[{'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','icon':'https://www.fillmurray.com/60/60','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'},{'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'}]" _default: "[{'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','icon':'https://www.fillmurray.com/60/60','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'},{'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'}]"
NEXT_PUBLIC_API_HOST: NEXT_PUBLIC_API_HOST:
_default: blockscout.com _default: blockscout.com
review: blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH: NEXT_PUBLIC_API_BASE_PATH:
_default: / _default: /
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM:
...@@ -380,5 +381,9 @@ frontend: ...@@ -380,5 +381,9 @@ frontend:
_default: https://blockscoutcom.us.auth0.com/v2/logout _default: https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_LOGOUT_RETURN_URL: NEXT_PUBLIC_LOGOUT_RETURN_URL:
_default: https://blockscout.com/auth/logout _default: https://blockscout.com/auth/logout
review: blockscout-main.test.aws-k8s.blockscout.com/auth/logout
NEXT_PUBLIC_HOMEPAGE_CHARTS: NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs','coin_price','market_cup']" _default: "['daily_txs','coin_price','market_cup']"
NEXT_PUBLIC_APP_HOST:
_default: blockscout.com
review: blockscout-main.test.aws-k8s.blockscout.com
...@@ -13,7 +13,7 @@ interface Props extends ChakraProviderProps { ...@@ -13,7 +13,7 @@ interface Props extends ChakraProviderProps {
export function Chakra({ cookies, theme, children }: Props) { export function Chakra({ cookies, theme, children }: Props) {
const colorModeManager = const colorModeManager =
typeof cookies === 'string' ? typeof cookies === 'string' ?
cookieStorageManagerSSR(cookies) : cookieStorageManagerSSR(typeof document !== 'undefined' ? document.cookie : cookies) :
localStorageManager; localStorageManager;
return ( return (
......
export default function getPlaceholderWithError(text: string, errorText?: string) {
return `${ text }${ errorText ? ' - ' + errorText : '' }`;
}
import _clamp from 'lodash/clamp';
import React from 'react';
const MAX_DELAY = 500;
const MIN_DELAY = 50;
export default function useGradualIncrement(initialValue: number): [number, (inc: number) => void] {
const [ num, setNum ] = React.useState(initialValue);
const queue = React.useRef<number>(0);
const timeoutId = React.useRef(0);
const incrementDelayed = React.useCallback(() => {
if (queue.current === 0) {
return;
}
queue.current--;
setNum(prev => prev + 1);
timeoutId.current = 0;
}, []);
const increment = React.useCallback((inc: number) => {
if (inc < 1) {
return;
}
queue.current += inc;
if (!timeoutId.current) {
timeoutId.current = window.setTimeout(incrementDelayed, 0);
}
}, [ incrementDelayed ]);
React.useEffect(() => {
if (queue.current > 0 && !timeoutId.current) {
const delay = _clamp(MAX_DELAY / queue.current * 1.5, MIN_DELAY, MAX_DELAY);
timeoutId.current = window.setTimeout(incrementDelayed, delay);
}
}, [ incrementDelayed, num ]);
React.useEffect(() => {
return () => {
window.clearTimeout(timeoutId.current);
};
}, []);
return [ num, increment ];
}
...@@ -2,6 +2,7 @@ import type { NextRouter } from 'next/router'; ...@@ -2,6 +2,7 @@ import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import { ROUTES } from 'lib/link/routes'; import { ROUTES } from 'lib/link/routes';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
...@@ -36,19 +37,19 @@ function assertIsNewPendingTxResponse(response: unknown): response is { pending_ ...@@ -36,19 +37,19 @@ function assertIsNewPendingTxResponse(response: unknown): response is { pending_
export default function useNewTxsSocket() { export default function useNewTxsSocket() {
const router = useRouter(); const router = useRouter();
const [ num, setNum ] = React.useState(0); const [ num, setNum ] = useGradualIncrement(0);
const [ socketAlert, setSocketAlert ] = React.useState(''); const [ socketAlert, setSocketAlert ] = React.useState('');
const { topic, event } = getSocketParams(router); const { topic, event } = getSocketParams(router);
const handleNewTxMessage = React.useCallback((response: { transaction: number } | { pending_transaction: number } | unknown) => { const handleNewTxMessage = React.useCallback((response: { transaction: number } | { pending_transaction: number } | unknown) => {
if (assertIsNewTxResponse(response)) { if (assertIsNewTxResponse(response)) {
setNum((prev) => prev + response.transaction); setNum(response.transaction);
} }
if (assertIsNewPendingTxResponse(response)) { if (assertIsNewPendingTxResponse(response)) {
setNum((prev) => prev + response.pending_transaction); setNum(response.pending_transaction);
} }
}, []); }, [ setNum ]);
const handleSocketClose = React.useCallback(() => { const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please click here to load new transactions.'); setSocketAlert('Connection is lost. Please click here to load new transactions.');
......
...@@ -6,7 +6,7 @@ export const SocketContext = React.createContext<Socket | null>(null); ...@@ -6,7 +6,7 @@ export const SocketContext = React.createContext<Socket | null>(null);
interface SocketProviderProps { interface SocketProviderProps {
children: React.ReactNode; children: React.ReactNode;
url: string; url?: string;
options?: Partial<SocketConnectOption>; options?: Partial<SocketConnectOption>;
} }
...@@ -14,6 +14,10 @@ export function SocketProvider({ children, options, url }: SocketProviderProps) ...@@ -14,6 +14,10 @@ export function SocketProvider({ children, options, url }: SocketProviderProps)
const [ socket, setSocket ] = useState<Socket | null>(null); const [ socket, setSocket ] = useState<Socket | null>(null);
useEffect(() => { useEffect(() => {
if (!url) {
return;
}
const socketInstance = new Socket(url, options); const socketInstance = new Socket(url, options);
socketInstance.connect(); socketInstance.connect();
setSocket(socketInstance); setSocket(socketInstance);
......
...@@ -68,6 +68,7 @@ ...@@ -68,6 +68,7 @@
"@types/phoenix": "^1.5.4", "@types/phoenix": "^1.5.4",
"@types/react": "18.0.9", "@types/react": "18.0.9",
"@types/react-dom": "18.0.5", "@types/react-dom": "18.0.5",
"@types/ws": "^8.5.3",
"@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/eslint-plugin": "^5.27.0",
"dotenv-cli": "^6.0.0", "dotenv-cli": "^6.0.0",
"eslint": "8.16.0", "eslint": "8.16.0",
...@@ -86,7 +87,8 @@ ...@@ -86,7 +87,8 @@
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "4.7.2", "typescript": "4.7.2",
"vite-plugin-svgr": "^2.2.2", "vite-plugin-svgr": "^2.2.2",
"vite-tsconfig-paths": "^3.5.2" "vite-tsconfig-paths": "^3.5.2",
"ws": "^8.11.0"
}, },
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --cache --fix" "*.{js,jsx,ts,tsx}": "eslint --cache --fix"
......
...@@ -10,11 +10,11 @@ class MyDocument extends Document { ...@@ -10,11 +10,11 @@ class MyDocument extends Document {
<Html lang="en"> <Html lang="en">
<Head> <Head>
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link <link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link rel="icon" sizes="32x32" type="image/png" href="/static/favicon-32x32.png"/> <link rel="icon" sizes="32x32" type="image/png" href="/static/favicon-32x32.png"/>
......
...@@ -2,13 +2,16 @@ import { ChakraProvider } from '@chakra-ui/react'; ...@@ -2,13 +2,16 @@ 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 { SocketProvider } from 'lib/socket/context';
import { PORT } from 'playwright/fixtures/socketServer';
import theme from 'theme'; import theme from 'theme';
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
withSocket?: boolean;
} }
const TestApp = ({ children }: Props) => { const TestApp = ({ children, withSocket }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({ const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
...@@ -21,7 +24,9 @@ const TestApp = ({ children }: Props) => { ...@@ -21,7 +24,9 @@ const TestApp = ({ children }: Props) => {
return ( return (
<ChakraProvider theme={ theme }> <ChakraProvider theme={ theme }>
<QueryClientProvider client={ queryClient }> <QueryClientProvider client={ queryClient }>
{ children } <SocketProvider url={ withSocket ? `ws://localhost:${ PORT }` : undefined }>
{ children }
</SocketProvider>
</QueryClientProvider> </QueryClientProvider>
</ChakraProvider> </ChakraProvider>
); );
......
import type { TestFixture } from '@playwright/test';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import type { NewBlockSocketResponse } from 'types/api/block';
type ReturnType = () => Promise<WebSocket>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ArgsType = any;
type Channel = [string, string, string];
export interface SocketServerFixture {
createSocket: ReturnType;
}
export const PORT = 3200;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const createSocket: TestFixture<ReturnType, ArgsType> = async({ page }, use) => {
const socketServer = new WebSocketServer({ port: PORT });
const connectionPromise = new Promise<WebSocket>((resolve) => {
socketServer.on('connection', (socket: WebSocket) => {
resolve(socket);
});
});
await use(() => connectionPromise);
socketServer.close();
};
export const joinChannel = async(socket: WebSocket, channelName: string) => {
return new Promise<[string, string, string]>((resolve, reject) => {
socket.on('message', (msg) => {
try {
const payload: Array<string> = JSON.parse(msg.toString());
if (channelName === payload[2] && payload[3] === 'phx_join') {
socket.send(JSON.stringify([
payload[0],
payload[1],
payload[2],
'phx_reply',
{ response: {}, status: 'ok' },
]));
resolve([ payload[0], payload[1], payload[2] ]);
}
} catch (error) {
reject(error);
}
});
});
};
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block', payload: NewBlockSocketResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
socket.send(JSON.stringify([
...channel,
msg,
payload,
]));
}
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -3,17 +3,39 @@ import { ...@@ -3,17 +3,39 @@ import {
createMultiStyleConfigHelpers, createMultiStyleConfigHelpers,
defineStyle, defineStyle,
} from '@chakra-ui/styled-system'; } from '@chakra-ui/styled-system';
import { mode } from '@chakra-ui/theme-tools';
import { runIfFn } from '@chakra-ui/utils';
const { definePartsStyle, defineMultiStyleConfig } = const { definePartsStyle, defineMultiStyleConfig } =
createMultiStyleConfigHelpers(parts.keys); createMultiStyleConfigHelpers(parts.keys);
const baseStyleControl = defineStyle((props) => {
const { colorScheme: c } = props;
return {
_checked: {
bg: mode(`${ c }.500`, `${ c }.300`)(props),
borderColor: mode(`${ c }.500`, `${ c }.300`)(props),
_hover: {
bg: mode(`${ c }.600`, `${ c }.400`)(props),
borderColor: mode(`${ c }.600`, `${ c }.400`)(props),
},
},
_indeterminate: {
bg: mode(`${ c }.500`, `${ c }.300`)(props),
borderColor: mode(`${ c }.500`, `${ c }.300`)(props),
},
};
});
const baseStyleLabel = defineStyle({ const baseStyleLabel = defineStyle({
_disabled: { opacity: 0.2 }, _disabled: { opacity: 0.2 },
}); });
const baseStyle = definePartsStyle({ const baseStyle = definePartsStyle((props) => ({
label: baseStyleLabel, label: baseStyleLabel,
}); control: runIfFn(baseStyleControl, props),
}));
const Checkbox = defineMultiStyleConfig({ const Checkbox = defineMultiStyleConfig({
baseStyle, baseStyle,
......
...@@ -4,6 +4,7 @@ import { getColor, mode } from '@chakra-ui/theme-tools'; ...@@ -4,6 +4,7 @@ import { getColor, mode } from '@chakra-ui/theme-tools';
import getDefaultFormColors from '../utils/getDefaultFormColors'; import getDefaultFormColors from '../utils/getDefaultFormColors';
const baseStyle = defineStyle({ const baseStyle = defineStyle({
display: 'flex',
fontSize: 'md', fontSize: 'md',
marginEnd: '3', marginEnd: '3',
mb: '2', mb: '2',
......
...@@ -9,10 +9,16 @@ const { defineMultiStyleConfig, definePartsStyle } = ...@@ -9,10 +9,16 @@ const { defineMultiStyleConfig, definePartsStyle } =
const baseStyleLabel = defineStyle({ const baseStyleLabel = defineStyle({
_disabled: { opacity: 0.2 }, _disabled: { opacity: 0.2 },
width: 'fit-content',
});
const baseStyleContainer = defineStyle({
width: 'fit-content',
}); });
const baseStyle = definePartsStyle({ const baseStyle = definePartsStyle({
label: baseStyleLabel, label: baseStyleLabel,
container: baseStyleContainer,
}); });
const Radio = defineMultiStyleConfig({ const Radio = defineMultiStyleConfig({
......
import { switchAnatomy as parts } from '@chakra-ui/anatomy';
import { defineStyle, createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
import { mode } from '@chakra-ui/theme-tools';
const { defineMultiStyleConfig, definePartsStyle } =
createMultiStyleConfigHelpers(parts.keys);
const baseStyleTrack = defineStyle((props) => {
const { colorScheme: c } = props;
return {
_checked: {
bg: mode(`${ c }.500`, `${ c }.300`)(props),
_hover: {
bg: mode(`${ c }.600`, `${ c }.400`)(props),
},
},
};
});
const baseStyle = definePartsStyle((props) => ({
track: baseStyleTrack(props),
}));
const Switch = defineMultiStyleConfig({
baseStyle,
});
export default Switch;
...@@ -36,8 +36,7 @@ const sizes = { ...@@ -36,8 +36,7 @@ const sizes = {
fontSize: 'sm', fontSize: 'sm',
}, },
td: { td: {
px: 4, p: 4,
py: 6,
}, },
}), }),
sm: definePartsStyle({ sm: definePartsStyle({
...@@ -48,7 +47,7 @@ const sizes = { ...@@ -48,7 +47,7 @@ const sizes = {
}, },
td: { td: {
px: '10px', px: '10px',
py: 6, py: 4,
fontSize: 'sm', fontSize: 'sm',
fontWeight: 500, fontWeight: 500,
}, },
...@@ -61,7 +60,7 @@ const sizes = { ...@@ -61,7 +60,7 @@ const sizes = {
}, },
td: { td: {
px: '6px', px: '6px',
py: 6, py: 4,
fontSize: 'sm', fontSize: 'sm',
fontWeight: 500, fontWeight: 500,
}, },
......
...@@ -34,6 +34,7 @@ const baseStyleContainer = defineStyle({ ...@@ -34,6 +34,7 @@ const baseStyleContainer = defineStyle({
display: 'inline-block', display: 'inline-block',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
borderRadius: 'sm', borderRadius: 'sm',
...transitionProps, ...transitionProps,
}); });
......
...@@ -13,6 +13,7 @@ import Popover from './Popover'; ...@@ -13,6 +13,7 @@ import Popover from './Popover';
import Radio from './Radio'; import Radio from './Radio';
import Skeleton from './Skeleton'; import Skeleton from './Skeleton';
import Spinner from './Spinner'; import Spinner from './Spinner';
import Switch from './Switch';
import Table from './Table'; import Table from './Table';
import Tabs from './Tabs'; import Tabs from './Tabs';
import Tag from './Tag'; import Tag from './Tag';
...@@ -36,6 +37,7 @@ const components = { ...@@ -36,6 +37,7 @@ const components = {
Radio, Radio,
Skeleton, Skeleton,
Spinner, Spinner,
Switch,
Tabs, Tabs,
Table, Table,
Tag, Tag,
......
...@@ -25,8 +25,11 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) { ...@@ -25,8 +25,11 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) {
_disabled: { _disabled: {
opacity: 1, opacity: 1,
backgroundColor: mode('gray.200', 'whiteAlpha.200')(props), backgroundColor: mode('gray.200', 'whiteAlpha.200')(props),
border: 'none', borderColor: 'transparent',
cursor: 'not-allowed', cursor: 'not-allowed',
_hover: {
borderColor: 'transparent',
},
}, },
_invalid: { _invalid: {
borderColor: getColor(theme, ec), borderColor: getColor(theme, ec),
......
export type StatsSection = { id: StatsSectionIds; title: string; charts: Array<StatsChart> }
export type StatsSectionIds = keyof typeof StatsSectionId;
export enum StatsSectionId { export enum StatsSectionId {
'all', 'all',
'accounts', 'accounts',
...@@ -5,9 +7,9 @@ export enum StatsSectionId { ...@@ -5,9 +7,9 @@ export enum StatsSectionId {
'transactions', 'transactions',
'gas', 'gas',
} }
export type StatsSectionIds = keyof typeof StatsSectionId;
export type StatsSection = { id: StatsSectionIds; value: string }
export type StatsInterval = { id: StatsIntervalIds; title: string }
export type StatsIntervalIds = keyof typeof StatsIntervalId;
export enum StatsIntervalId { export enum StatsIntervalId {
'all', 'all',
'oneMonth', 'oneMonth',
...@@ -15,5 +17,11 @@ export enum StatsIntervalId { ...@@ -15,5 +17,11 @@ export enum StatsIntervalId {
'sixMonths', 'sixMonths',
'oneYear', 'oneYear',
} }
export type StatsIntervalIds = keyof typeof StatsIntervalId;
export type StatsInterval = { id: StatsIntervalIds; value: string } export type StatsChart = {
visible?: boolean;
id: string;
title: string;
description: string;
apiMethodURL: string;
}
...@@ -15,9 +15,9 @@ import type { ApiKey, ApiKeys, ApiKeyErrors } from 'types/api/account'; ...@@ -15,9 +15,9 @@ import type { ApiKey, ApiKeys, ApiKeyErrors } from 'types/api/account';
import { QueryKeys } from 'types/client/accountQueries'; import { QueryKeys } from 'types/client/accountQueries';
import getErrorMessage from 'lib/getErrorMessage'; import getErrorMessage from 'lib/getErrorMessage';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import type { ErrorType } from 'lib/hooks/useFetch'; import type { ErrorType } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Props = { type Props = {
data?: ApiKey; data?: ApiKey;
...@@ -33,7 +33,7 @@ type Inputs = { ...@@ -33,7 +33,7 @@ type Inputs = {
const NAME_MAX_LENGTH = 255; const NAME_MAX_LENGTH = 255;
const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isValid, isDirty }, setError } = useForm<Inputs>({
mode: 'all', mode: 'all',
defaultValues: { defaultValues: {
token: data?.api_key || '', token: data?.api_key || '',
...@@ -71,7 +71,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -71,7 +71,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
}); });
} }
return [ ...(prevData || []), response ]; return [ response, ...(prevData || []) ];
}); });
onClose(); onClose();
...@@ -113,7 +113,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -113,7 +113,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
maxLength={ NAME_MAX_LENGTH } maxLength={ NAME_MAX_LENGTH }
/> />
<FormLabel> <FormLabel>
{ getPlaceholderWithError('Application name for API key (e.g Web3 project)', errors.name?.message) } <InputPlaceholder text="Application name for API key (e.g Web3 project)" error={ errors.name?.message }/>
</FormLabel> </FormLabel>
</FormControl> </FormControl>
); );
...@@ -145,7 +145,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -145,7 +145,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
<Button <Button
size="lg" size="lg"
type="submit" type="submit"
disabled={ !isValid } disabled={ !isValid || !isDirty }
isLoading={ mutation.isLoading } isLoading={ mutation.isLoading }
> >
{ data ? 'Save' : 'Generate API key' } { data ? 'Save' : 'Generate API key' }
......
...@@ -30,7 +30,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -30,7 +30,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const renderText = useCallback(() => { const renderText = useCallback(() => {
return ( return (
<Text> API key for <Text fontWeight="600" as="span">{ ` "${ data.name || 'name' }" ` }</Text> will be deleted </Text> <Text> API key for <Text fontWeight="700" as="span">{ ` "${ data.name || 'name' }" ` }</Text> will be deleted </Text>
); );
}, [ data.name ]); }, [ data.name ]);
......
import { test, expect } from '@playwright/experimental-ct-react'; import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as blockMock from 'mocks/blocks/block'; import * as blockMock from 'mocks/blocks/block';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import BlocksContent from './BlocksContent'; import BlocksContent from './BlocksContent';
const API_URL = '/node-api/blocks'; const API_URL = '/node-api/blocks';
export const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
// FIXME
// test cases which use socket cannot run in parallel since the socket server always run on the same port
test.describe.configure({ mode: 'serial' });
test('base view +@mobile', async({ mount, page }) => { test('base view +@mobile', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({ await page.route(API_URL, (route) => route.fulfill({
status: 200, status: 200,
...@@ -19,8 +28,52 @@ test('base view +@mobile', async({ mount, page }) => { ...@@ -19,8 +28,52 @@ test('base view +@mobile', async({ mount, page }) => {
<BlocksContent/> <BlocksContent/>
</TestApp>, </TestApp>,
); );
await page.waitForResponse(API_URL);
await expect(component).toHaveScreenshot();
});
test('new item from socket', async({ mount, page, createSocket }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blockMock.baseListResponse),
}));
const component = await mount(
<TestApp withSocket>
<BlocksContent/>
</TestApp>,
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'blocks:new_block');
socketServer.sendMessage(socket, channel, 'new_block', {
average_block_time: '6212.0',
block: {
...blockMock.base,
height: blockMock.base.height + 1,
timestamp: '2022-11-11T11:59:58Z',
},
});
await expect(component).toHaveScreenshot();
});
test('socket error', async({ mount, page, createSocket }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blockMock.baseListResponse),
}));
const component = await mount(
<TestApp withSocket>
<BlocksContent/>
</TestApp>,
);
await page.waitForResponse(API_URL), const socket = await createSocket();
await socketServer.joinChannel(socket, 'blocks:new_block');
socket.close();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -2,7 +2,6 @@ import { ...@@ -2,7 +2,6 @@ import {
Box, Box,
Button, Button,
FormControl, FormControl,
FormLabel,
Input, Input,
Textarea, Textarea,
useColorModeValue, useColorModeValue,
...@@ -16,11 +15,11 @@ import type { CustomAbi, CustomAbis, CustomAbiErrors } from 'types/api/account'; ...@@ -16,11 +15,11 @@ import type { CustomAbi, CustomAbis, CustomAbiErrors } from 'types/api/account';
import { QueryKeys } from 'types/client/accountQueries'; import { QueryKeys } from 'types/client/accountQueries';
import getErrorMessage from 'lib/getErrorMessage'; import getErrorMessage from 'lib/getErrorMessage';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import type { ErrorType } from 'lib/hooks/useFetch'; import type { ErrorType } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import { ADDRESS_REGEXP } from 'lib/validations/address'; import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput'; import AddressInput from 'ui/shared/AddressInput';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Props = { type Props = {
data?: CustomAbi; data?: CustomAbi;
...@@ -37,7 +36,7 @@ type Inputs = { ...@@ -37,7 +36,7 @@ type Inputs = {
const NAME_MAX_LENGTH = 255; const NAME_MAX_LENGTH = 255;
const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const { control, formState: { errors, isValid }, handleSubmit, setError } = useForm<Inputs>({ const { control, formState: { errors, isValid, isDirty }, handleSubmit, setError } = useForm<Inputs>({
defaultValues: { defaultValues: {
contract_address_hash: data?.contract_address_hash || '', contract_address_hash: data?.contract_address_hash || '',
name: data?.name || '', name: data?.name || '',
...@@ -77,7 +76,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -77,7 +76,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
}); });
} }
return [ ...(prevData || []), response ]; return [ response, ...(prevData || []) ];
}); });
onClose(); onClose();
...@@ -119,7 +118,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -119,7 +118,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
isInvalid={ Boolean(errors.name) } isInvalid={ Boolean(errors.name) }
maxLength={ NAME_MAX_LENGTH } maxLength={ NAME_MAX_LENGTH }
/> />
<FormLabel>{ getPlaceholderWithError('Project name', errors.name?.message) }</FormLabel> <InputPlaceholder text="Project name" error={ errors.name?.message }/>
</FormControl> </FormControl>
); );
}, [ errors, formBackgroundColor ]); }, [ errors, formBackgroundColor ]);
...@@ -133,7 +132,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -133,7 +132,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
minH="300px" minH="300px"
isInvalid={ Boolean(errors.abi) } isInvalid={ Boolean(errors.abi) }
/> />
<FormLabel>{ getPlaceholderWithError(`Custom ABI [{...}] (JSON format)`, errors.abi?.message) }</FormLabel> <InputPlaceholder text="Custom ABI [{...}] (JSON format)" error={ errors.abi?.message }/>
</FormControl> </FormControl>
); );
}, [ errors, formBackgroundColor ]); }, [ errors, formBackgroundColor ]);
...@@ -171,7 +170,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -171,7 +170,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
<Button <Button
size="lg" size="lg"
type="submit" type="submit"
disabled={ !isValid } disabled={ !isValid || !isDirty }
isLoading={ mutation.isLoading } isLoading={ mutation.isLoading }
> >
{ data ? 'Save' : 'Create custom ABI' } { data ? 'Save' : 'Create custom ABI' }
......
...@@ -29,7 +29,7 @@ const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -29,7 +29,7 @@ const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const renderText = useCallback(() => { const renderText = useCallback(() => {
return ( return (
<Text>Custom ABI for<Text fontWeight="600" as="span">{ ` "${ data.name || 'name' }" ` }</Text>will be deleted</Text> <Text>Custom ABI for<Text fontWeight="700" as="span">{ ` "${ data.name || 'name' }" ` }</Text>will be deleted</Text>
); );
}, [ data.name ]); }, [ data.name ]);
......
import { Alert, Spinner, Text, Link, useColorModeValue, useTheme } from '@chakra-ui/react'; import { Alert, Text, Link, useColorModeValue, useTheme } from '@chakra-ui/react';
import { transparentize } from '@chakra-ui/theme-tools'; import { transparentize } from '@chakra-ui/theme-tools';
import React from 'react'; import React from 'react';
...@@ -18,18 +18,14 @@ const LatestTxsNotice = ({ className }: Props) => { ...@@ -18,18 +18,14 @@ const LatestTxsNotice = ({ className }: Props) => {
content = 'Connection is lost. Please reload page'; content = 'Connection is lost. Please reload page';
} else if (!num) { } else if (!num) {
content = ( content = (
<> <Text>scanning new transactions...</Text>
<Spinner size="sm" mr={ 3 }/>
<Text>scanning new transactions ...</Text>
</>
); );
} else { } else {
const txsUrl = link('txs'); const txsUrl = link('txs');
content = ( content = (
<> <>
<Spinner size="sm" mr={ 3 }/> <Link href={ txsUrl }>{ num } more transaction{ num > 1 ? 's' : '' }</Link>
<Text as="span" whiteSpace="pre">+ { num } new transaction{ num > 1 ? 's' : '' }. </Text> <Text whiteSpace="pre"> ha{ num > 1 ? 've' : 's' } come in</Text>
<Link href={ txsUrl }>View all</Link>
</> </>
); );
} }
......
...@@ -57,7 +57,7 @@ const ApiKeysPage: React.FC = () => { ...@@ -57,7 +57,7 @@ const ApiKeysPage: React.FC = () => {
const description = ( const description = (
<AccountPageDescription> <AccountPageDescription>
Create API keys to use for your RPC and EthRPC API requests. For more information, see { space } Create API keys to use for your RPC and EthRPC API requests. For more information, see { space }
<Link href="https://docs.blockscout.com/for-users/api#api-keys">“How to use a Blockscout API key”</Link>. <Link href="https://docs.blockscout.com/for-users/api#api-keys" target="_blank">“How to use a Blockscout API key”</Link>.
</AccountPageDescription> </AccountPageDescription>
); );
...@@ -107,7 +107,12 @@ const ApiKeysPage: React.FC = () => { ...@@ -107,7 +107,12 @@ const ApiKeysPage: React.FC = () => {
<> <>
{ description } { description }
{ Boolean(data.length) && list } { Boolean(data.length) && list }
<Stack marginTop={ 8 } spacing={ 5 } direction={{ base: 'column', lg: 'row' }}> <Stack
marginTop={ 8 }
spacing={ 5 }
direction={{ base: 'column', lg: 'row' }}
align={{ base: 'start', lg: 'center' }}
>
<Button <Button
size="lg" size="lg"
onClick={ apiKeyModalProps.onOpen } onClick={ apiKeyModalProps.onOpen }
......
...@@ -24,7 +24,7 @@ const BlockPageContent = () => { ...@@ -24,7 +24,7 @@ const BlockPageContent = () => {
return ( return (
<Page> <Page>
<PageTitle text={ `Block #${ router.query.id }` }/> <PageTitle text={ `Block #${ router.query.id }` }/>
<RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 12 }}/> <RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 8 }}/>
</Page> </Page>
); );
}; };
......
...@@ -23,7 +23,7 @@ const MyProfile = () => { ...@@ -23,7 +23,7 @@ const MyProfile = () => {
} }
return ( return (
<VStack maxW="412px" mt={ 12 } gap={ 5 } alignItems="stretch"> <VStack maxW="412px" mt={ 8 } gap={ 5 } alignItems="stretch">
<UserAvatar size={ 64 } data={ data } isFetched={ isFetched }/> <UserAvatar size={ 64 } data={ data } isFetched={ isFetched }/>
<FormControl variant="floating" id="name" isRequired size="lg"> <FormControl variant="floating" id="name" isRequired size="lg">
<Input <Input
......
...@@ -5,18 +5,36 @@ import Page from 'ui/shared/Page/Page'; ...@@ -5,18 +5,36 @@ import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import StatsFilters from '../stats/StatsFilters'; import StatsFilters from '../stats/StatsFilters';
import useStats from '../stats/useStats';
import WidgetsList from '../stats/WidgetsList'; import WidgetsList from '../stats/WidgetsList';
const Stats = () => { const Stats = () => {
const {
section,
handleSectionChange,
interval,
handleIntervalChange,
debounceFilterCharts,
displayedCharts,
} = useStats();
return ( return (
<Page> <Page>
<PageTitle text="Ethereum Stats"/> <PageTitle text="Ethereum Stats"/>
<Box mb={{ base: 6, sm: 8 }}> <Box mb={{ base: 6, sm: 8 }}>
<StatsFilters/> <StatsFilters
section={ section }
onSectionChange={ handleSectionChange }
interval={ interval }
onIntervalChange={ handleIntervalChange }
onFilterInputChange={ debounceFilterCharts }
/>
</Box> </Box>
<WidgetsList/> <WidgetsList
charts={ displayedCharts }
/>
</Page> </Page>
); );
}; };
......
...@@ -82,7 +82,7 @@ const TransactionPageContent = () => { ...@@ -82,7 +82,7 @@ const TransactionPageContent = () => {
</Flex> </Flex>
) } ) }
</Flex> </Flex>
<RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 12 }}/> <RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 8 }}/>
</Page> </Page>
); );
}; };
......
...@@ -34,7 +34,7 @@ type Inputs = { ...@@ -34,7 +34,7 @@ type Inputs = {
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const fetch = useFetch(); const fetch = useFetch();
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isValid, isDirty }, setError } = useForm<Inputs>({
mode: 'all', mode: 'all',
defaultValues: { defaultValues: {
address: data?.address_hash || '', address: data?.address_hash || '',
...@@ -120,7 +120,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -120,7 +120,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
<Button <Button
size="lg" size="lg"
type="submit" type="submit"
disabled={ !isValid } disabled={ !isValid || !isDirty }
isLoading={ pending } isLoading={ pending }
> >
{ data ? 'Save changes' : 'Add tag' } { data ? 'Save changes' : 'Add tag' }
......
...@@ -40,7 +40,7 @@ const DeletePrivateTagModal: React.FC<Props> = ({ isOpen, onClose, data, type }) ...@@ -40,7 +40,7 @@ const DeletePrivateTagModal: React.FC<Props> = ({ isOpen, onClose, data, type })
const renderText = useCallback(() => { const renderText = useCallback(() => {
return ( return (
<Text>Tag<Text fontWeight="600" as="span">{ ` "${ tag || 'tag' }" ` }</Text>will be deleted</Text> <Text>Tag<Text fontWeight="700" as="span">{ ` "${ tag || 'tag' }" ` }</Text>will be deleted</Text>
); );
}, [ tag ]); }, [ tag ]);
......
...@@ -35,7 +35,7 @@ const TransactionForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => ...@@ -35,7 +35,7 @@ const TransactionForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) =>
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const formBackgroundColor = useColorModeValue('white', 'gray.900'); const formBackgroundColor = useColorModeValue('white', 'gray.900');
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isValid, isDirty }, setError } = useForm<Inputs>({
mode: 'all', mode: 'all',
defaultValues: { defaultValues: {
transaction: data?.transaction_hash || '', transaction: data?.transaction_hash || '',
...@@ -119,7 +119,7 @@ const TransactionForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => ...@@ -119,7 +119,7 @@ const TransactionForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) =>
<Button <Button
size="lg" size="lg"
type="submit" type="submit"
disabled={ !isValid } disabled={ !isValid || !isDirty }
isLoading={ pending } isLoading={ pending }
> >
{ data ? 'Save changes' : 'Add tag' } { data ? 'Save changes' : 'Add tag' }
......
...@@ -48,7 +48,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete ...@@ -48,7 +48,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
text = ( text = (
<> <>
<Text display="inline" as="span">Public tag</Text> <Text display="inline" as="span">Public tag</Text>
<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tags[0] }" ` }</Text> <Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tags[0] }" ` }</Text>
<Text as="span">will be removed.</Text> <Text as="span">will be removed.</Text>
</> </>
); );
...@@ -57,15 +57,15 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete ...@@ -57,15 +57,15 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
const tagsText: Array<JSX.Element | string> = []; const tagsText: Array<JSX.Element | string> = [];
tags.forEach((tag, index) => { tags.forEach((tag, index) => {
if (index < tags.length - 2) { if (index < tags.length - 2) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tag }"` }</Text>); tagsText.push(<Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tag }"` }</Text>);
tagsText.push(','); tagsText.push(',');
} }
if (index === tags.length - 2) { if (index === tags.length - 2) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>); tagsText.push(<Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>);
tagsText.push('and'); tagsText.push('and');
} }
if (index === tags.length - 1) { if (index === tags.length - 1) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>); tagsText.push(<Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>);
} }
}); });
text = ( text = (
...@@ -76,7 +76,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete ...@@ -76,7 +76,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
} }
return ( return (
<> <>
<Box marginBottom={ 12 }> <Box marginBottom={ 8 }>
{ text } { text }
</Box> </Box>
<FormControl variant="floating" id="tag-delete" backgroundColor={ formBackgroundColor }> <FormControl variant="floating" id="tag-delete" backgroundColor={ formBackgroundColor }>
......
...@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'; ...@@ -4,7 +4,7 @@ import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form'; import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError'; import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from './PublicTagsForm'; import type { Inputs } from './PublicTagsForm';
...@@ -25,7 +25,7 @@ export default function PublicTagFormComment({ control, error, size }: Props) { ...@@ -25,7 +25,7 @@ export default function PublicTagFormComment({ control, error, size }: Props) {
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
/> />
<FormLabel> <FormLabel>
{ getPlaceholderWithError('Specify the reason for adding tags and color preference(s)', error?.message) } <InputPlaceholder text="Specify the reason for adding tags and color preference(s)" error={ error?.message }/>
</FormLabel> </FormLabel>
</FormControl> </FormControl>
); );
......
...@@ -61,7 +61,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -61,7 +61,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const fetch = useFetch(); const fetch = useFetch();
const inputSize = { base: 'md', lg: 'lg' }; const inputSize = { base: 'md', lg: 'lg' };
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isValid, isDirty }, setError } = useForm<Inputs>({
defaultValues: { defaultValues: {
fullName: data?.full_name || '', fullName: data?.full_name || '',
email: data?.email || '', email: data?.email || '',
...@@ -123,7 +123,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -123,7 +123,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
}); });
} }
return [ ...(prevData || []), response ]; return [ response, ...(prevData || []) ];
}); });
changeToDataScreen(true); changeToDataScreen(true);
...@@ -237,7 +237,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -237,7 +237,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
<Button <Button
size="lg" size="lg"
type="submit" type="submit"
disabled={ !isValid } disabled={ !isValid || !isDirty }
isLoading={ mutation.isLoading } isLoading={ mutation.isLoading }
> >
Send request Send request
......
import type { InputProps } from '@chakra-ui/react'; import type { InputProps } from '@chakra-ui/react';
import { FormControl, FormLabel, Input } from '@chakra-ui/react'; import { FormControl, Input } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { ControllerRenderProps, FieldError, FieldValues, Path, Control } from 'react-hook-form'; import type { ControllerRenderProps, FieldError, FieldValues, Path, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError'; import InputPlaceholder from 'ui/shared/InputPlaceholder';
const TEXT_INPUT_MAX_LENGTH = 255; const TEXT_INPUT_MAX_LENGTH = 255;
...@@ -36,7 +36,7 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({ ...@@ -36,7 +36,7 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
maxLength={ TEXT_INPUT_MAX_LENGTH } maxLength={ TEXT_INPUT_MAX_LENGTH }
/> />
<FormLabel>{ getPlaceholderWithError(label, error?.message) }</FormLabel> <InputPlaceholder text={ label } error={ error?.message }/>
</FormControl> </FormControl>
); );
}, [ label, required, error, size ]); }, [ label, required, error, size ]);
......
...@@ -39,7 +39,7 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => { ...@@ -39,7 +39,7 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
); );
return ( return (
<Box position="relative" marginBottom={{ base: 6, lg: 12 }}> <Box position="relative" marginBottom={{ base: 6, lg: 8 }}>
<Text <Text
ref={ ref } ref={ ref }
maxHeight={ needCut && !expanded ? `${ CUT_HEIGHT }px` : 'auto' } maxHeight={ needCut && !expanded ? `${ CUT_HEIGHT }px` : 'auto' }
......
...@@ -2,13 +2,12 @@ import type { InputProps } from '@chakra-ui/react'; ...@@ -2,13 +2,12 @@ import type { InputProps } from '@chakra-ui/react';
import { import {
Input, Input,
FormControl, FormControl,
FormLabel,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ControllerRenderProps, FieldError, FieldValues, Path } from 'react-hook-form'; import type { ControllerRenderProps, FieldError, FieldValues, Path } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import { ADDRESS_LENGTH } from 'lib/validations/address'; import { ADDRESS_LENGTH } from 'lib/validations/address';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = { type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>; field: ControllerRenderProps<TInputs, TInputName>;
...@@ -33,7 +32,7 @@ export default function AddressInput<Inputs extends FieldValues, Name extends Pa ...@@ -33,7 +32,7 @@ export default function AddressInput<Inputs extends FieldValues, Name extends Pa
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH } maxLength={ ADDRESS_LENGTH }
/> />
<FormLabel>{ getPlaceholderWithError(placeholder, error?.message) }</FormLabel> <InputPlaceholder text={ placeholder } error={ error?.message }/>
</FormControl> </FormControl>
); );
} }
...@@ -13,6 +13,13 @@ interface Props { ...@@ -13,6 +13,13 @@ interface Props {
} }
const CurrencyValue = ({ value, currency = '', decimals, exchangeRate, className, accuracy, accuracyUsd }: Props) => { const CurrencyValue = ({ value, currency = '', decimals, exchangeRate, className, accuracy, accuracyUsd }: Props) => {
if (value === undefined || value === null) {
return (
<Box as="span" className={ className }>
<Text>N/A</Text>
</Box>
);
}
const valueCurr = BigNumber(value).div(BigNumber(10 ** Number(decimals || '18'))); const valueCurr = BigNumber(value).div(BigNumber(10 ** Number(decimals || '18')));
const valueResult = accuracy ? valueCurr.dp(accuracy).toFormat() : valueCurr.toFormat(); const valueResult = accuracy ? valueCurr.dp(accuracy).toFormat() : valueCurr.toFormat();
......
...@@ -49,6 +49,7 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder }: Props) = ...@@ -49,6 +49,7 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder }: Props) =
placeholder={ placeholder } placeholder={ placeholder }
borderWidth="2px" borderWidth="2px"
textOverflow="ellipsis" textOverflow="ellipsis"
whiteSpace="nowrap"
/> />
{ filterQuery ? ( { filterQuery ? (
......
...@@ -49,7 +49,7 @@ export default function FormModal<TData>({ ...@@ -49,7 +49,7 @@ export default function FormModal<TData>({
<ModalCloseButton/> <ModalCloseButton/>
<ModalBody mb={ 0 }> <ModalBody mb={ 0 }>
{ (isAlertVisible || text) && ( { (isAlertVisible || text) && (
<Box marginBottom={{ base: 6, lg: 12 }}> <Box marginBottom={{ base: 6, lg: 8 }}>
{ text && ( { text && (
<Text lineHeight="30px" mb={ 3 }> <Text lineHeight="30px" mb={ 3 }>
{ text } { text }
......
import { FormLabel, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
text: string;
error?: string;
}
const InputPlaceholder = ({ text, error }: Props) => {
return (
<FormLabel>
<chakra.span>{ text }</chakra.span>
{ error && <chakra.span order={ 3 } whiteSpace="pre"> - { error }</chakra.span> }
</FormLabel>
);
};
export default InputPlaceholder;
...@@ -3,7 +3,7 @@ import React from 'react'; ...@@ -3,7 +3,7 @@ import React from 'react';
const PageTitle = ({ text }: {text: string}) => { const PageTitle = ({ text }: {text: string}) => {
return ( return (
<Heading as="h1" size="lg" marginBottom={{ base: 6, lg: 8 }}>{ text }</Heading> <Heading as="h1" size="lg" marginBottom={ 6 }>{ text }</Heading>
); );
}; };
......
import { import {
Input, Input,
FormControl, FormControl,
FormLabel,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ControllerRenderProps, FieldError, FieldValues, Path } from 'react-hook-form'; import type { ControllerRenderProps, FieldError, FieldValues, Path } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError'; import InputPlaceholder from 'ui/shared/InputPlaceholder';
const TAG_MAX_LENGTH = 35; const TAG_MAX_LENGTH = 35;
...@@ -24,7 +23,7 @@ function TagInput<Inputs extends FieldValues, Name extends Path<Inputs>>({ field ...@@ -24,7 +23,7 @@ function TagInput<Inputs extends FieldValues, Name extends Path<Inputs>>({ field
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
maxLength={ TAG_MAX_LENGTH } maxLength={ TAG_MAX_LENGTH }
/> />
<FormLabel>{ getPlaceholderWithError(`Private tag (max 35 characters)`, error?.message) }</FormLabel> <InputPlaceholder text="Private tag (max 35 characters)" error={ error?.message }/>
</FormControl> </FormControl>
); );
} }
......
import { import {
Input, Input,
FormControl, FormControl,
FormLabel,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ControllerRenderProps, FieldError, FieldValues } from 'react-hook-form'; import type { ControllerRenderProps, FieldError, FieldValues } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import { TRANSACTION_HASH_LENGTH } from 'lib/validations/transaction'; import { TRANSACTION_HASH_LENGTH } from 'lib/validations/transaction';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Props<Field> = { type Props<Field> = {
field: Field; field: Field;
...@@ -23,7 +22,7 @@ function TransactionInput<Field extends Partial<ControllerRenderProps<FieldValue ...@@ -23,7 +22,7 @@ function TransactionInput<Field extends Partial<ControllerRenderProps<FieldValue
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
maxLength={ TRANSACTION_HASH_LENGTH } maxLength={ TRANSACTION_HASH_LENGTH }
/> />
<FormLabel>{ getPlaceholderWithError('Transaction hash (0x...)', error?.message) }</FormLabel> <InputPlaceholder text="Transaction hash (0x...)" error={ error?.message }/>
</FormControl> </FormControl>
); );
} }
......
...@@ -35,7 +35,7 @@ const AddressLink = ({ alias, type, className, truncation = 'dynamic', hash, id, ...@@ -35,7 +35,7 @@ const AddressLink = ({ alias, type, className, truncation = 'dynamic', hash, id,
if (alias) { if (alias) {
return ( return (
<Tooltip label={ hash }> <Tooltip label={ hash }>
<Box overflow="hidden" textOverflow="ellipsis">{ alias }</Box> <Box overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ alias }</Box>
</Tooltip> </Tooltip>
); );
} }
......
...@@ -41,7 +41,7 @@ const NavLink = ({ text, url, icon, isCollapsed, isActive, px, isNewUi }: Props) ...@@ -41,7 +41,7 @@ const NavLink = ({ text, url, icon, isCollapsed, isActive, px, isNewUi }: Props)
isDisabled={ !isCollapsed } isDisabled={ !isCollapsed }
placement="right" placement="right"
variant="nav" variant="nav"
gutter={ 15 } gutter={ 20 }
color={ isActive ? colors.text.active : colors.text.hover } color={ isActive ? colors.text.active : colors.text.hover }
> >
<HStack spacing={ 3 }> <HStack spacing={ 3 }>
......
...@@ -73,7 +73,7 @@ const NavigationDesktop = () => { ...@@ -73,7 +73,7 @@ const NavigationDesktop = () => {
<NetworkLogo isCollapsed={ isCollapsed }/> <NetworkLogo isCollapsed={ isCollapsed }/>
<NetworkMenu isCollapsed={ isCollapsed }/> <NetworkMenu isCollapsed={ isCollapsed }/>
</Box> </Box>
<Box as="nav" mt={ 14 }> <Box as="nav" mt={ 8 }>
<VStack as="ul" spacing="2" alignItems="flex-start" overflow="hidden"> <VStack as="ul" spacing="2" alignItems="flex-start" overflow="hidden">
{ mainNavItems.map((item) => <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>) } { mainNavItems.map((item) => <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>) }
</VStack> </VStack>
......
...@@ -65,6 +65,7 @@ const ChartWidget = ({ title, description }: Props) => { ...@@ -65,6 +65,7 @@ const ChartWidget = ({ title, description }: Props) => {
items={ demoData } items={ demoData }
onZoom={ handleZoom } onZoom={ handleZoom }
isZoomResetInitial={ isZoomResetInitial } isZoomResetInitial={ isZoomResetInitial }
title={ title }
/> />
</Box> </Box>
); );
......
...@@ -14,6 +14,7 @@ import useChartSize from 'ui/shared/chart/useChartSize'; ...@@ -14,6 +14,7 @@ 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 {
title: string;
items: Array<TimeChartItem>; items: Array<TimeChartItem>;
onZoom: () => void; onZoom: () => void;
isZoomResetInitial: boolean; isZoomResetInitial: boolean;
...@@ -21,7 +22,7 @@ interface Props { ...@@ -21,7 +22,7 @@ interface Props {
const CHART_MARGIN = { bottom: 20, left: 65, right: 30, top: 10 }; const CHART_MARGIN = { bottom: 20, left: 65, right: 30, top: 10 };
const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial }: Props) => { const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial, title }: Props) => {
const ref = React.useRef<SVGSVGElement>(null); const ref = React.useRef<SVGSVGElement>(null);
const [ range, setRange ] = React.useState<[ number, number ]>([ 0, Infinity ]); const [ range, setRange ] = React.useState<[ number, number ]>([ 0, Infinity ]);
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN); const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN);
...@@ -30,7 +31,7 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial }: Props) => { ...@@ -30,7 +31,7 @@ const ChartWidgetGraph = ({ items, onZoom, isZoomResetInitial }: Props) => {
const displayedData = useMemo(() => items.slice(range[0], range[1]).map((d) => const displayedData = useMemo(() => items.slice(range[0], range[1]).map((d) =>
({ ...d, date: new Date(d.date) })), [ items, range ]); ({ ...d, date: new Date(d.date) })), [ items, range ]);
const chartData = [ { items: items, name: 'chart', color } ]; const chartData = [ { items: items, name: title, color } ];
const { yTickFormat, xScale, yScale } = useTimeChartController({ const { yTickFormat, xScale, yScale } = useTimeChartController({
data: [ { items: displayedData, name: 'chart', color } ], data: [ { items: displayedData, name: 'chart', color } ],
......
...@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'; ...@@ -4,7 +4,7 @@ import React, { useCallback } from 'react';
import eastMiniArrowIcon from 'icons/arrows/east-mini.svg'; import eastMiniArrowIcon from 'icons/arrows/east-mini.svg';
type Props<T extends string> = { type Props<T extends string> = {
items: Array<{id: T; value: string}>; items: Array<{id: T; title: string}>;
selectedId: T; selectedId: T;
onSelect: (id: T) => void; onSelect: (id: T) => void;
} }
...@@ -32,7 +32,7 @@ export function StatsDropdownMenu<T extends string>({ items, selectedId, onSelec ...@@ -32,7 +32,7 @@ export function StatsDropdownMenu<T extends string>({ items, selectedId, onSelec
display="flex" display="flex"
alignItems="center" alignItems="center"
> >
{ selectedCategory?.value } { selectedCategory?.title }
<Icon transform="rotate(-90deg)" ml={{ base: 'auto', sm: 1 }} as={ eastMiniArrowIcon } w={ 5 } h={ 5 }/> <Icon transform="rotate(-90deg)" ml={{ base: 'auto', sm: 1 }} as={ eastMiniArrowIcon } w={ 5 } h={ 5 }/>
</Box> </Box>
</MenuButton> </MenuButton>
...@@ -48,7 +48,7 @@ export function StatsDropdownMenu<T extends string>({ items, selectedId, onSelec ...@@ -48,7 +48,7 @@ export function StatsDropdownMenu<T extends string>({ items, selectedId, onSelec
key={ item.id } key={ item.id }
value={ item.id } value={ item.id }
> >
{ item.value } { item.title }
</MenuItemOption> </MenuItemOption>
)) } )) }
</MenuOptionGroup> </MenuOptionGroup>
......
import { Grid, GridItem } from '@chakra-ui/react'; import { Grid, GridItem } from '@chakra-ui/react';
import debounce from 'lodash/debounce'; import React from 'react';
import React, { useCallback, useState } from 'react';
import type { StatsInterval, StatsIntervalIds, StatsSection, StatsSectionIds } from 'types/client/stats'; import type { StatsInterval, StatsIntervalIds, StatsSection, StatsSectionIds } from 'types/client/stats';
...@@ -11,21 +10,29 @@ import StatsDropdownMenu from './StatsDropdownMenu'; ...@@ -11,21 +10,29 @@ import StatsDropdownMenu from './StatsDropdownMenu';
const sectionsList = Object.keys(STATS_SECTIONS).map((id: string) => ({ const sectionsList = Object.keys(STATS_SECTIONS).map((id: string) => ({
id: id, id: id,
value: STATS_SECTIONS[id as StatsSectionIds], title: STATS_SECTIONS[id as StatsSectionIds],
})) as Array<StatsSection>; })) as Array<StatsSection>;
const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({ const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({
id: id, id: id,
value: STATS_INTERVALS[id as StatsIntervalIds], title: STATS_INTERVALS[id as StatsIntervalIds],
})) as Array<StatsInterval>; })) as Array<StatsInterval>;
const StatsFilters = () => { type Props = {
const [ selectedSectionId, setSelectedSectionId ] = useState<StatsSectionIds>('all'); section: StatsSectionIds;
const [ selectedIntervalId, setSelectedIntervalId ] = useState<StatsIntervalIds>('all'); onSectionChange: (newSection: StatsSectionIds) => void;
const [ , setFilterQuery ] = useState(''); interval: StatsIntervalIds;
onIntervalChange: (newInterval: StatsIntervalIds) => void;
onFilterInputChange: (q: string) => void;
}
// eslint-disable-next-line react-hooks/exhaustive-deps const StatsFilters = ({
const debounceFilterCharts = useCallback(debounce(q => setFilterQuery(q), 500), []); section,
onSectionChange,
interval,
onIntervalChange,
onFilterInputChange,
}: Props) => {
return ( return (
<Grid <Grid
...@@ -42,7 +49,7 @@ const StatsFilters = () => { ...@@ -42,7 +49,7 @@ const StatsFilters = () => {
area="input" area="input"
> >
<FilterInput <FilterInput
onChange={ debounceFilterCharts } onChange={ onFilterInputChange }
placeholder="Find chart, metric..."/> placeholder="Find chart, metric..."/>
</GridItem> </GridItem>
...@@ -52,8 +59,8 @@ const StatsFilters = () => { ...@@ -52,8 +59,8 @@ const StatsFilters = () => {
> >
<StatsDropdownMenu <StatsDropdownMenu
items={ sectionsList } items={ sectionsList }
selectedId={ selectedSectionId } selectedId={ section }
onSelect={ setSelectedSectionId } onSelect={ onSectionChange }
/> />
</GridItem> </GridItem>
...@@ -63,8 +70,8 @@ const StatsFilters = () => { ...@@ -63,8 +70,8 @@ const StatsFilters = () => {
> >
<StatsDropdownMenu <StatsDropdownMenu
items={ intervalList } items={ intervalList }
selectedId={ selectedIntervalId } selectedId={ interval }
onSelect={ setSelectedIntervalId } onSelect={ onIntervalChange }
/> />
</GridItem> </GridItem>
</Grid> </Grid>
......
import { Grid, GridItem, Heading, List, ListItem } from '@chakra-ui/react'; import { Grid, GridItem, Heading, List, ListItem } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { StatsSection } from 'types/client/stats';
import { apos } from 'lib/html-entities';
import EmptySearchResult from '../apps/EmptySearchResult';
import ChartWidget from './ChartWidget'; import ChartWidget from './ChartWidget';
import { statisticsChartsScheme } from './constants/charts-scheme';
const WidgetsList = () => { type Props = {
return ( charts: Array<StatsSection>;
}
const WidgetsList = ({ charts }: Props) => {
const isAnyChartDisplayed = charts.some((section) => section.charts.some(chart => chart.visible));
return isAnyChartDisplayed ? (
<List> <List>
{ {
statisticsChartsScheme.map((section) => ( charts.map((section) => (
<ListItem <ListItem
display={ section.charts.every((chart) => !chart.visible) ? 'none' : 'block' }
key={ section.id } key={ section.id }
mb={ 8 } mb={ 8 }
_last={{ _last={{
...@@ -30,7 +41,10 @@ const WidgetsList = () => { ...@@ -30,7 +41,10 @@ const WidgetsList = () => {
gap={ 4 } gap={ 4 }
> >
{ section.charts.map((chart) => ( { section.charts.map((chart) => (
<GridItem key={ chart.id }> <GridItem
key={ chart.id }
display={ chart.visible ? 'block' : 'none' }
>
<ChartWidget <ChartWidget
apiMethodURL={ chart.apiMethodURL } apiMethodURL={ chart.apiMethodURL }
title={ chart.title } title={ chart.title }
...@@ -43,6 +57,8 @@ const WidgetsList = () => { ...@@ -43,6 +57,8 @@ const WidgetsList = () => {
)) ))
} }
</List> </List>
) : (
<EmptySearchResult text={ `Couldn${ apos }t find a chart that matches your filter query.` }/>
); );
}; };
......
export const statisticsChartsScheme = [ import type { StatsSection } from 'types/client/stats';
export const statsChartsScheme: Array<StatsSection> = [
{ {
id: 'blocks', id: 'blocks',
title: 'Blocks', title: 'Blocks',
......
import type { TimeChartItem } from '../../shared/chart/types'; import type { TimeChartItem } from 'ui/shared/chart/types';
export const demoData: Array<TimeChartItem> = [ { date: new Date('2022-10-17T00:00:00.000Z'), value: 432670 }, { export const demoData: Array<TimeChartItem> = [ { date: new Date('2022-10-17T00:00:00.000Z'), value: 432670 }, {
date: new Date('2022-10-18T00:00:00.000Z'), date: new Date('2022-10-18T00:00:00.000Z'),
......
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useState } from 'react';
import type { StatsChart, StatsIntervalIds, StatsSection, StatsSectionIds } from 'types/client/stats';
import { statsChartsScheme } from './constants/charts-scheme';
function isSectionMatches(section: StatsSection, currentSection: StatsSectionIds): boolean {
return currentSection === 'all' || section.id === currentSection;
}
function isChartNameMatches(q: string, chart: StatsChart) {
return chart.title.toLowerCase().includes(q.toLowerCase());
}
export default function useStats() {
const [ isLoading, setIsLoading ] = useState(true);
const [ defaultCharts, setDefaultCharts ] = useState<Array<StatsSection>>();
const [ displayedCharts, setDisplayedCharts ] = useState<Array<StatsSection>>([]);
const [ section, setSection ] = useState<StatsSectionIds>('all');
const [ interval, setInterval ] = useState<StatsIntervalIds>('all');
const [ filterQuery, setFilterQuery ] = useState('');
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFilterCharts = useCallback(debounce(q => setFilterQuery(q), 500), []);
const filterCharts = useCallback((q: string, currentSection: StatsSectionIds) => {
const charts = defaultCharts
?.map((section: StatsSection) => {
const charts = section.charts.map((chart: StatsChart) => ({
...chart,
visible: isSectionMatches(section, currentSection) && isChartNameMatches(q, chart),
}));
return {
...section,
charts,
};
});
setDisplayedCharts(charts || []);
}, [ defaultCharts ]);
const handleSectionChange = useCallback((newSection: StatsSectionIds) => {
setSection(newSection);
}, []);
const handleIntervalChange = useCallback((newInterval: StatsIntervalIds) => {
setInterval(newInterval);
}, []);
useEffect(() => {
filterCharts(filterQuery, section);
}, [ filterQuery, section, filterCharts ]);
useEffect(() => {
setDefaultCharts(statsChartsScheme);
setDisplayedCharts(statsChartsScheme);
setIsLoading(false);
}, []);
return React.useMemo(() => ({
section,
handleSectionChange,
interval,
handleIntervalChange,
debounceFilterCharts,
isLoading,
displayedCharts,
}), [
section,
handleSectionChange,
interval,
handleIntervalChange,
debounceFilterCharts,
displayedCharts,
isLoading,
]);
}
import { Alert, Spinner, Text, Link, chakra } from '@chakra-ui/react'; import { Alert, Link, Text, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket'; import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
...@@ -36,14 +36,17 @@ const TxsNewItemNotice = ({ children, className }: Props) => { ...@@ -36,14 +36,17 @@ const TxsNewItemNotice = ({ children, className }: Props) => {
} }
if (!num) { if (!num) {
return null; return (
<Alert className={ className } status="warning" p={ 4 } fontWeight={ 400 }>
scanning new transactions...
</Alert>
);
} }
return ( return (
<Alert className={ className } status="warning" p={ 4 } fontWeight={ 400 }> <Alert className={ className } status="warning" p={ 4 } fontWeight={ 400 }>
<Spinner size="sm" mr={ 3 }/> <Link onClick={ handleClick }>{ num } more transaction{ num > 1 ? 's' : '' }</Link>
<Text as="span" whiteSpace="pre">+ { num } new transaction{ num > 1 ? 's' : '' }. </Text> <Text whiteSpace="pre"> ha{ num > 1 ? 've' : 's' } come in</Text>
<Link onClick={ handleClick }>View in list</Link>
</Alert> </Alert>
); );
})(); })();
......
...@@ -73,7 +73,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -73,7 +73,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
notificationsDefault = data.notification_settings; notificationsDefault = data.notification_settings;
} }
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isValid, isDirty }, setError } = useForm<Inputs>({
defaultValues: { defaultValues: {
address: data?.address_hash || '', address: data?.address_hash || '',
tag: data?.name || '', tag: data?.name || '',
...@@ -191,7 +191,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -191,7 +191,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
size="lg" size="lg"
type="submit" type="submit"
isLoading={ pending } isLoading={ pending }
disabled={ !isValid } disabled={ !isValid || !isDirty }
> >
{ data ? 'Save changes' : 'Add address' } { data ? 'Save changes' : 'Add address' }
</Button> </Button>
......
...@@ -35,7 +35,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -35,7 +35,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const renderModalContent = useCallback(() => { const renderModalContent = useCallback(() => {
const addressString = isMobile ? [ address.slice(0, 4), address.slice(-4) ].join('...') : address; const addressString = isMobile ? [ address.slice(0, 4), address.slice(-4) ].join('...') : address;
return ( return (
<Text>Address <Text fontWeight="600" as="span"> { addressString || 'address' }</Text> will be deleted</Text> <Text>Address <Text fontWeight="700" as="span"> { addressString || 'address' }</Text> will be deleted</Text>
); );
}, [ address, isMobile ]); }, [ address, isMobile ]);
......
import { HStack, VStack, Text, Icon, useColorModeValue, Flex } from '@chakra-ui/react'; import { HStack, VStack, Text, Icon, Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TWatchlistItem } from 'types/client/account'; import type { TWatchlistItem } from 'types/client/account';
...@@ -12,12 +12,10 @@ import CurrencyValue from 'ui/shared/CurrencyValue'; ...@@ -12,12 +12,10 @@ import CurrencyValue from 'ui/shared/CurrencyValue';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => { const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
const mainTextColor = useColorModeValue('gray.700', 'gray.50'); const infoItemsPaddingLeft = { base: 1, lg: 8 };
const infoItemsPaddingLeft = { base: 0, lg: 8 };
return ( return (
<VStack spacing={ 2 } align="stretch" fontWeight={ 500 } color="gray.700"> <VStack spacing={ 2 } align="stretch" fontWeight={ 500 }>
<AddressSnippet address={ item.address_hash }/> <AddressSnippet address={ item.address_hash }/>
<Flex fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft } flexWrap="wrap" alignItems="center" rowGap={ 1 }> <Flex fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft } flexWrap="wrap" alignItems="center" rowGap={ 1 }>
{ appConfig.network.currency.address && ( { appConfig.network.currency.address && (
...@@ -40,8 +38,8 @@ const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => { ...@@ -40,8 +38,8 @@ const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
</Flex> </Flex>
{ item.tokens_count && ( { item.tokens_count && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }> <HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }>
<Icon as={ TokensIcon } marginRight="10px" w="17px" h="16px"/> <Icon as={ TokensIcon } mr={ 2 } w="17px" h="16px"/>
<Text color={ mainTextColor }>{ `Tokens:${ nbsp }` + item.tokens_count }</Text> <Text>{ `Tokens:${ nbsp }` + item.tokens_count }</Text>
{ /* api does not provide token prices */ } { /* api does not provide token prices */ }
{ /* <Text variant="secondary">{ `${ nbsp }($${ item.tokensUSD } USD)` }</Text> */ } { /* <Text variant="secondary">{ `${ nbsp }($${ item.tokensUSD } USD)` }</Text> */ }
<Text variant="secondary">{ `${ nbsp }(N/A)` }</Text> <Text variant="secondary">{ `${ nbsp }(N/A)` }</Text>
...@@ -50,8 +48,8 @@ const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => { ...@@ -50,8 +48,8 @@ const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
{ /* api does not provide token prices */ } { /* api does not provide token prices */ }
{ /* { item.address_balance && ( { /* { item.address_balance && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }> <HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }>
<Icon as={ WalletIcon } marginRight="10px" w="16px" h="16px"/> <Icon as={ WalletIcon } mr={ 2 } w="16px" h="16px"/>
<Text color={ mainTextColor }>{ `Net worth:${ nbsp }` }</Text> <Text>{ `Net worth:${ nbsp }` }</Text>
<Link href="#">{ `$${ item.totalUSD } USD` }</Link> <Link href="#">{ `$${ item.totalUSD } USD` }</Link>
</HStack> </HStack>
) } */ } ) } */ }
......
...@@ -3525,6 +3525,13 @@ ...@@ -3525,6 +3525,13 @@
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
"@types/ws@^8.5.3":
version "8.5.3"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d"
integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==
dependencies:
"@types/node" "*"
"@types/yargs-parser@*": "@types/yargs-parser@*":
version "21.0.0" version "21.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
...@@ -8971,7 +8978,7 @@ ws@7.4.6: ...@@ -8971,7 +8978,7 @@ ws@7.4.6:
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
ws@^8.9.0: ws@^8.11.0, ws@^8.9.0:
version "8.11.0" version "8.11.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
......
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