Commit f5c890f9 authored by Max Alekseenko's avatar Max Alekseenko

merge main

parents e6975fa4 65e6b205
## 🚀 New Features
- Description of the new feature 1.
- Description of the new feature 2.
## 🐛 Bug Fixes
- Description of the bug fix 1.
- Description of the bug fix 2.
## ⚡ Performance Improvements
- Description of the performance improvement 1.
- Description of the performance improvement 2.
## 📦 Dependencies updates
- Updated dependency: PackageName 1 to version x.x.x.
- Updated dependency: PackageName 2 to version x.x.x.
## ✨ Other Changes
- Another minor change 1.
- Another minor change 2.
## 🚨 Changes in ENV variables
- Added new environment variable: ENV_VARIABLE_NAME with value.
- Updated existing environment variable: ENV_VARIABLE_NAME to new value.
**Full list of the ENV variables**: [v1.2.3](https://github.com/blockscout/frontend/blob/v1.2.3/docs/ENVS.md)
## 🦄 New Contributors
- @contributor1 made their first contribution in https://github.com/blockscout/frontend/pull/1
- @contributor2 made their first contribution in https://github.com/blockscout/frontend/pull/2
---
**Full Changelog**: https://github.com/blockscout/frontend/compare/v1.2.2...v1.2.3
...@@ -349,12 +349,20 @@ const accountSchema = yup ...@@ -349,12 +349,20 @@ const accountSchema = yup
then: (schema) => schema.test(urlTest).required(), then: (schema) => schema.test(urlTest).required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_LOGOUT_URL cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'), otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_LOGOUT_URL cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'),
}), }),
});
const adminServiceSchema = yup
.object()
.shape({
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup
.string() .string()
.when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', { .when([ 'NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', 'NEXT_PUBLIC_MARKETPLACE_ENABLED' ], {
is: (value: boolean) => value, is: (value1: boolean, value2: boolean) => value1 || value2,
then: (schema) => schema.test(urlTest), then: (schema) => schema.test(urlTest),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'), otherwise: (schema) => schema.max(
-1,
'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED or NEXT_PUBLIC_MARKETPLACE_ENABLED is not set to "true"',
),
}), }),
}); });
...@@ -631,6 +639,7 @@ const schema = yup ...@@ -631,6 +639,7 @@ const schema = yup
.concat(rollupSchema) .concat(rollupSchema)
.concat(beaconChainSchema) .concat(beaconChainSchema)
.concat(bridgedTokensSchema) .concat(bridgedTokensSchema)
.concat(sentrySchema); .concat(sentrySchema)
.concat(adminServiceSchema);
export default schema; export default schema;
#!/bin/bash #!/bin/bash
secrets_file=".env.secrets"
test_folder="./test" test_folder="./test"
common_file="${test_folder}/.env.common" common_file="${test_folder}/.env.common"
...@@ -8,7 +7,6 @@ common_file="${test_folder}/.env.common" ...@@ -8,7 +7,6 @@ common_file="${test_folder}/.env.common"
export NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) export NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD)
export NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) export NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0)
../../scripts/collect_envs.sh ../../../docs/ENVS.md ../../scripts/collect_envs.sh ../../../docs/ENVS.md
cp ../../../.env.example ${secrets_file}
# Copy test assets # Copy test assets
mkdir -p "./public/assets" mkdir -p "./public/assets"
...@@ -26,7 +24,6 @@ validate_file() { ...@@ -26,7 +24,6 @@ validate_file() {
dotenv \ dotenv \
-e $test_file \ -e $test_file \
-e $common_file \ -e $common_file \
-e $secrets_file \
yarn run validate -- --silent yarn run validate -- --silent
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
...@@ -46,4 +43,4 @@ for file in "${test_files[@]}"; do ...@@ -46,4 +43,4 @@ for file in "${test_files[@]}"; do
if [ $? -eq 1 ]; then if [ $? -eq 1 ]; then
exit 1 exit 1
fi fi
done done
\ No newline at end of file
NEXT_PUBLIC_SENTRY_DSN=https://sentry.io
NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
FAVICON_GENERATOR_API_KEY=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AD_TEXT_PROVIDER=coinzilla NEXT_PUBLIC_AD_TEXT_PROVIDER=coinzilla
NEXT_PUBLIC_AD_BANNER_PROVIDER=slise NEXT_PUBLIC_AD_BANNER_PROVIDER=slise
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
...@@ -60,4 +72,4 @@ NEXT_PUBLIC_VISUALIZE_API_BASE_PATH=https://example.com ...@@ -60,4 +72,4 @@ NEXT_PUBLIC_VISUALIZE_API_BASE_PATH=https://example.com
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket'] NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
NEXT_PUBLIC_SWAP_BUTTON_URL=uniswap NEXT_PUBLIC_SWAP_BUTTON_URL=uniswap
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE=stability NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE=stability
\ No newline at end of file
NEXT_PUBLIC_API_HOST=blockscout.com NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_APP_HOST=localhost NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_NETWORK_ID=1 NEXT_PUBLIC_NETWORK_ID=1
NEXT_PUBLIC_NETWORK_NAME=Testnet NEXT_PUBLIC_NETWORK_NAME=Testnet
...@@ -50,11 +50,11 @@ frontend: ...@@ -50,11 +50,11 @@ frontend:
NEXT_PUBLIC_APP_ENV: development NEXT_PUBLIC_APP_ENV: development
NEXT_PUBLIC_APP_INSTANCE: review NEXT_PUBLIC_APP_INSTANCE: review
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: validation NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: validation
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-sepolia.json
NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/sepolia.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/sepolia.png
NEXT_PUBLIC_API_HOST: eth-sepolia.blockscout.com NEXT_PUBLIC_API_HOST: eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/ NEXT_PUBLIC_STATS_API_HOST: https://stats-sepolia.k8s-dev.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/ NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
......
...@@ -200,6 +200,8 @@ Settings for meta tags, OG tags and SEO ...@@ -200,6 +200,8 @@ Settings for meta tags, OG tags and SEO
| `total_reward` | Total block reward | | `total_reward` | Total block reward |
| `nonce` | Block nonce | | `nonce` | Block nonce |
| `miner` | Address of block's miner or validator | | `miner` | Address of block's miner or validator |
| `L1_status` | Short interpretation of the batch lifecycle (applicable for Rollup chains) |
| `batch` | Batch index (applicable for Rollup chains) |
   
...@@ -234,6 +236,8 @@ Settings for meta tags, OG tags and SEO ...@@ -234,6 +236,8 @@ Settings for meta tags, OG tags and SEO
| `tx_fee` | Total transaction fee | | `tx_fee` | Total transaction fee |
| `gas_fees` | Gas fees breakdown | | `gas_fees` | Gas fees breakdown |
| `burnt_fees` | Amount of native coin burnt for transaction | | `burnt_fees` | Amount of native coin burnt for transaction |
| `L1_status` | Short interpretation of the batch lifecycle (applicable for Rollup chains) |
| `batch` | Batch index (applicable for Rollup chains) |
##### Transaction additional fields list ##### Transaction additional fields list
| Id | Description | | Id | Description |
......
<svg viewBox="0 0 240 192" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 185" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M82.667 4H56a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4ZM56 0a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H56Zm122.667 4H152a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4ZM152 0a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H152ZM56 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4V56a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V56Zm34.667 44H56a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V104a4 4 0 0 0-4-4ZM56 96a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V104a8 8 0 0 0-8-8H56Zm0 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4V152a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V152ZM178.667 52H152a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V56a4 4 0 0 0-4-4ZM152 48a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8H152Zm0 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H152a4 4 0 0 1-4-4V104a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H152a8 8 0 0 1-8-8V104Zm34.667 44H152a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V152a4 4 0 0 0-4-4ZM152 144a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V152a8 8 0 0 0-8-8H152ZM8 28h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V32a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V32Zm104-4h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H104a4 4 0 0 1-4-4V32a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H104a8 8 0 0 1-8-8V32Zm130.667-4H200a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V32a4 4 0 0 0-4-4ZM200 24a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V32a8 8 0 0 0-8-8H200Zm-96 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H104a4 4 0 0 1-4-4V80a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H104a8 8 0 0 1-8-8V80Zm34.667 44H104a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V128a4 4 0 0 0-4-4ZM104 120a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V128a8 8 0 0 0-8-8H104Zm96-44h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H200a4 4 0 0 1-4-4V80a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H200a8 8 0 0 1-8-8V80Zm34.667 44H200a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V128a4 4 0 0 0-4-4ZM200 120a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V128a8 8 0 0 0-8-8H200ZM8 76h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V80a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V80Zm34.667 44H8a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V128a4 4 0 0 0-4-4ZM8 120a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V128a8 8 0 0 0-8-8H8Z" fill="url(#empty_search_result_svg__a)"/> <g clip-path="url(#a)">
<path d="m135.337 94.376 11.182 11.242-3.694 3.715-11.18-11.245a23.31 23.31 0 0 1-14.666 5.17c-12.971 0-23.498-10.586-23.498-23.629C93.48 66.586 104.008 56 116.979 56c12.971 0 23.499 10.586 23.499 23.629a23.614 23.614 0 0 1-5.141 14.747Zm-5.238-1.948a18.372 18.372 0 0 0 5.157-12.799c0-10.155-8.18-18.378-18.277-18.378-10.099 0-18.276 8.223-18.276 18.378 0 10.153 8.177 18.378 18.276 18.378a18.166 18.166 0 0 0 12.729-5.185l.391-.394Z" fill="#A0AEC0"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M26.307-2.023H6.923A7.2 7.2 0 0 0-.37 5.269v19.385a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292V5.269a7.25 7.25 0 0 0-7.293-7.292Zm-19.384-2.4A9.692 9.692 0 0 0-2.77 5.269v19.385a9.692 9.692 0 0 0 9.693 9.692h19.384A9.692 9.692 0 0 0 36 24.654V5.269a9.693 9.693 0 0 0-9.693-9.692H6.923Zm19.384 54.092H6.923A7.2 7.2 0 0 0-.37 56.962v19.384a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292V56.961a7.25 7.25 0 0 0-7.293-7.292Zm-19.384-2.4a9.693 9.693 0 0 0-9.693 9.693v19.384a9.692 9.692 0 0 0 9.693 9.692h19.384A9.692 9.692 0 0 0 36 76.346V56.961a9.693 9.693 0 0 0-9.693-9.692H6.923Zm0 54.093h19.384a7.249 7.249 0 0 1 7.293 7.292v19.384a7.25 7.25 0 0 1-7.293 7.293H6.923a7.209 7.209 0 0 1-5.184-2.108 7.208 7.208 0 0 1-2.109-5.185v-19.384a7.249 7.249 0 0 1 7.293-7.292Zm-9.693 7.292a9.693 9.693 0 0 1 9.693-9.692h19.384A9.694 9.694 0 0 1 36 108.654v19.384a9.694 9.694 0 0 1-9.693 9.693H6.923a9.691 9.691 0 0 1-9.693-9.693v-19.384Zm29.077 44.4H6.923a7.19 7.19 0 0 0-5.184 2.108 7.208 7.208 0 0 0-2.109 5.184v19.385a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292v-19.385a7.25 7.25 0 0 0-7.293-7.292Zm-19.384-2.4a9.691 9.691 0 0 0-9.693 9.692v19.385a9.691 9.691 0 0 0 9.693 9.692h19.384A9.691 9.691 0 0 0 36 179.731v-19.385a9.691 9.691 0 0 0-9.693-9.692H6.923Zm51.692-126.83H78a7.25 7.25 0 0 1 7.292 7.291V50.5A7.25 7.25 0 0 1 78 57.792H58.615a7.2 7.2 0 0 1-7.292-7.292V31.115a7.249 7.249 0 0 1 7.292-7.292Zm-9.692 7.291a9.692 9.692 0 0 1 9.692-9.692H78a9.692 9.692 0 0 1 9.692 9.692V50.5A9.692 9.692 0 0 1 78 60.192H58.615a9.692 9.692 0 0 1-9.692-9.692V31.115ZM78 75.515H58.615a7.2 7.2 0 0 0-7.292 7.293v19.384a7.25 7.25 0 0 0 7.292 7.293H78a7.25 7.25 0 0 0 7.292-7.293v-2.123c.39 1.933.977 3.819 1.754 5.631A9.689 9.689 0 0 1 78 111.885H58.615a9.695 9.695 0 0 1-9.692-9.693V82.808a9.692 9.692 0 0 1 9.692-9.693H78c4.061 0 7.569 2.493 9.046 6.185-.83 1.754-1.385 3.692-1.846 5.538v-2.03c0-4.062-3.139-7.293-7.2-7.293Zm61.384-12.83V56.96a9.694 9.694 0 0 0-9.692-9.692h-19.385a9.693 9.693 0 0 0-9.692 9.693v5.723l2.4-1.477v-4.246a7.25 7.25 0 0 1 7.292-7.293h19.385a7.249 7.249 0 0 1 7.292 7.293v4.246l2.4 1.477Zm13.57 43.015a9.691 9.691 0 0 0 9.046 6.185h19.384a9.696 9.696 0 0 0 9.693-9.693V82.808a9.695 9.695 0 0 0-9.693-9.693H162a9.69 9.69 0 0 0-9.046 6.185c.83 1.754 1.384 3.692 1.846 5.538v-2.03c0-4.062 3.138-7.293 7.2-7.293h19.384a7.25 7.25 0 0 1 7.293 7.293v19.384a7.25 7.25 0 0 1-7.293 7.293H162a7.188 7.188 0 0 1-5.184-2.109 7.188 7.188 0 0 1-2.109-5.184v-2.123a28.602 28.602 0 0 1-1.753 5.631Zm-52.339 16.615v5.816a9.69 9.69 0 0 0 9.692 9.692h19.385a9.692 9.692 0 0 0 9.692-9.692v-5.723l-2.4 1.384v4.339a7.249 7.249 0 0 1-7.292 7.292h-19.385a7.192 7.192 0 0 1-5.217-2.14 7.199 7.199 0 0 1-2.075-5.245v-4.153l-2.4-1.385v-.185Zm-42 4.893H78a7.249 7.249 0 0 1 7.292 7.292v19.385A7.25 7.25 0 0 1 78 161.177H58.615a7.21 7.21 0 0 1-5.184-2.108 7.209 7.209 0 0 1-2.108-5.184V134.5a7.248 7.248 0 0 1 7.292-7.292Zm-9.692 7.292a9.693 9.693 0 0 1 9.692-9.692H78a9.694 9.694 0 0 1 9.692 9.692v19.385A9.693 9.693 0 0 1 78 163.577H58.615a9.69 9.69 0 0 1-9.692-9.692V134.5ZM129.692-2.023h-19.385a7.198 7.198 0 0 0-7.292 7.292v19.385a7.25 7.25 0 0 0 7.292 7.292h19.385a7.249 7.249 0 0 0 7.292-7.292V5.269a7.249 7.249 0 0 0-7.292-7.292Zm-19.385-2.4a9.693 9.693 0 0 0-9.692 9.692v19.385a9.69 9.69 0 0 0 9.692 9.692h19.385a9.693 9.693 0 0 0 9.692-9.692V5.269a9.694 9.694 0 0 0-9.692-9.692h-19.385Zm-7.292 164.769a7.25 7.25 0 0 1 7.292-7.292h19.385a7.249 7.249 0 0 1 7.292 7.292v19.385a7.249 7.249 0 0 1-7.292 7.292h-19.385a7.207 7.207 0 0 1-5.184-2.108 7.216 7.216 0 0 1-2.108-5.184v-19.385Zm-2.4 0a9.69 9.69 0 0 1 9.692-9.692h19.385a9.692 9.692 0 0 1 9.692 9.692v19.385a9.693 9.693 0 0 1-9.692 9.692h-19.385a9.691 9.691 0 0 1-9.692-9.692v-19.385Zm80.769-136.523H162a7.206 7.206 0 0 0-5.184 2.108 7.195 7.195 0 0 0-2.109 5.184V50.5A7.25 7.25 0 0 0 162 57.792h19.384a7.25 7.25 0 0 0 7.293-7.292V31.115a7.25 7.25 0 0 0-7.293-7.292ZM162 21.423a9.691 9.691 0 0 0-9.693 9.692V50.5A9.695 9.695 0 0 0 162 60.192h19.384a9.693 9.693 0 0 0 9.693-9.692V31.115a9.695 9.695 0 0 0-9.693-9.692H162Zm19.384 105.785H162a7.186 7.186 0 0 0-5.184 2.108 7.193 7.193 0 0 0-2.109 5.184v19.385a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292V134.5a7.249 7.249 0 0 0-7.293-7.292Zm-19.384-2.4a9.695 9.695 0 0 0-9.693 9.692v19.385a9.696 9.696 0 0 0 9.693 9.692h19.384a9.692 9.692 0 0 0 9.693-9.692V134.5a9.695 9.695 0 0 0-9.693-9.692H162ZM213.692-2.023h19.385a7.25 7.25 0 0 1 7.292 7.292v19.385a7.25 7.25 0 0 1-7.292 7.292h-19.385a7.198 7.198 0 0 1-7.292-7.292V5.269a7.249 7.249 0 0 1 7.292-7.292ZM204 5.269a9.691 9.691 0 0 1 9.692-9.692h19.385a9.693 9.693 0 0 1 9.692 9.692v19.385a9.69 9.69 0 0 1-9.692 9.692h-19.385A9.69 9.69 0 0 1 204 24.654V5.269Zm29.077 44.4h-19.385a7.198 7.198 0 0 0-7.292 7.293v19.384a7.249 7.249 0 0 0 7.292 7.292h19.385a7.25 7.25 0 0 0 7.292-7.292V56.961a7.25 7.25 0 0 0-7.292-7.292Zm-19.385-2.4A9.69 9.69 0 0 0 204 56.962v19.384a9.69 9.69 0 0 0 9.692 9.692h19.385a9.693 9.693 0 0 0 9.692-9.692V56.961a9.691 9.691 0 0 0-9.692-9.692h-19.385Zm0 54.093h19.385a7.249 7.249 0 0 1 7.292 7.292v19.384a7.25 7.25 0 0 1-7.292 7.293h-19.385a7.207 7.207 0 0 1-5.184-2.108 7.2 7.2 0 0 1-2.108-5.185v-19.384a7.248 7.248 0 0 1 7.292-7.292ZM204 108.654a9.692 9.692 0 0 1 9.692-9.692h19.385a9.694 9.694 0 0 1 9.692 9.692v19.384a9.692 9.692 0 0 1-9.692 9.693h-19.385a9.692 9.692 0 0 1-9.692-9.693v-19.384Zm29.077 44.4h-19.385a7.189 7.189 0 0 0-5.184 2.108 7.2 7.2 0 0 0-2.108 5.184v19.385a7.249 7.249 0 0 0 7.292 7.292h19.385a7.25 7.25 0 0 0 7.292-7.292v-19.385a7.25 7.25 0 0 0-7.292-7.292Zm-19.385-2.4a9.69 9.69 0 0 0-9.692 9.692v19.385a9.69 9.69 0 0 0 9.692 9.692h19.385a9.691 9.691 0 0 0 9.692-9.692v-19.385a9.69 9.69 0 0 0-9.692-9.692h-19.385Z" fill="url(#b)"/>
<path d="M87.231 68.5c1.385-1.846 4.154-.923 4.154 1.477v.37a6.461 6.461 0 0 0 6.462 6.46h44.307a6.461 6.461 0 0 0 6.462-6.46v-.37c0-2.308 2.769-3.416 4.154-1.477.276.37.461.923.461 1.477v.37a11.078 11.078 0 0 1-11.077 11.076H97.847a11.077 11.077 0 0 1-11.078-11.077v-.37c0-.553.185-1.107.462-1.476Zm65.539 48c.276-.369.461-.923.461-1.477v-.369a11.08 11.08 0 0 0-11.077-11.077H97.847a11.078 11.078 0 0 0-11.078 11.077v.369c0 .554.185 1.108.462 1.477 1.385 1.846 4.154.923 4.154-1.477v-.369a6.461 6.461 0 0 1 6.462-6.462h44.307a6.46 6.46 0 0 1 6.462 6.462v.369c0 2.308 2.769 3.415 4.154 1.477Z" fill="#A0AEC0" fill-opacity=".5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M153.231 92.5a33.233 33.233 0 0 1-45.948 30.701 33.237 33.237 0 0 1-17.984-17.984A33.233 33.233 0 0 1 120 59.269 33.232 33.232 0 0 1 153.231 92.5Zm-5.262 33.877a3.963 3.963 0 0 0-4.984-.369 40.618 40.618 0 0 1-54.212-7.54 40.613 40.613 0 0 1 2.52-54.675 40.614 40.614 0 0 1 62.215 51.692c-1.108 1.569-.923 3.692.369 4.984l18.185 18.185a3.693 3.693 0 0 1 0 5.169l-.739.739a3.693 3.693 0 0 1-5.169 0l-18.185-18.185Z" fill="#A0AEC0"/>
</g>
<defs> <defs>
<radialGradient id="empty_search_result_svg__a" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="rotate(103.716 25.035 85.404) scale(58.3301 137.703)"> <radialGradient id="b" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 96.9231 -119.218 0 116.449 92.5)">
<stop offset=".081" stop-color="#CBD5E0" stop-opacity="0"/> <stop stop-color="#CBD5E0" stop-opacity=".8"/>
<stop offset=".563" stop-color="#CBD5E0" stop-opacity=".54"/>
<stop offset="1" stop-color="#CBD5E0" stop-opacity="0"/> <stop offset="1" stop-color="#CBD5E0" stop-opacity="0"/>
</radialGradient> </radialGradient>
<clipPath id="a">
<path fill="#fff" transform="translate(0 .192)" d="M0 0h240v184.615H0z"/>
</clipPath>
</defs> </defs>
</svg> </svg>
...@@ -20,6 +20,7 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr ...@@ -20,6 +20,7 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr
'appID', 'appID',
'logoURL', 'logoURL',
'text', 'text',
'tagUrl',
'tooltipIcon', 'tooltipIcon',
'tooltipTitle', 'tooltipTitle',
'tooltipDescription', 'tooltipDescription',
......
...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts'; ...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures { export interface GrowthBookFeatures {
test_value: string; test_value: string;
security_score_exp: boolean;
action_button_exp: boolean; action_button_exp: boolean;
} }
......
export default function makePrettyLink(url: string | undefined): { url: string; domain: string } | undefined {
try {
const urlObj = new URL(url ?? '');
return {
url: urlObj.href,
domain: urlObj.hostname,
};
} catch (error) {}
}
...@@ -109,6 +109,10 @@ Type extends EventTypes.PAGE_WIDGET ? ( ...@@ -109,6 +109,10 @@ Type extends EventTypes.PAGE_WIDGET ? (
'Type': 'Action button'; 'Type': 'Action button';
'Info': string; 'Info': string;
'Source': 'Txn' | 'NFT collection' | 'NFT item'; 'Source': 'Txn' | 'NFT collection' | 'NFT item';
} | {
'Type': 'Address tag';
'Info': string;
'URL': string;
} }
) : ) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
...@@ -28,6 +28,7 @@ SocketMessage.AddressTxs | ...@@ -28,6 +28,7 @@ SocketMessage.AddressTxs |
SocketMessage.AddressTxsPending | SocketMessage.AddressTxsPending |
SocketMessage.AddressTokenTransfer | SocketMessage.AddressTokenTransfer |
SocketMessage.AddressChangedBytecode | SocketMessage.AddressChangedBytecode |
SocketMessage.AddressFetchedBytecode |
SocketMessage.SmartContractWasVerified | SocketMessage.SmartContractWasVerified |
SocketMessage.TokenTransfers | SocketMessage.TokenTransfers |
SocketMessage.TokenTotalSupply | SocketMessage.TokenTotalSupply |
...@@ -64,6 +65,7 @@ export namespace SocketMessage { ...@@ -64,6 +65,7 @@ export namespace SocketMessage {
export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transactions: Array<Transaction> }>; export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transactions: Array<Transaction> }>;
export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfers: Array<TokenTransfer> }>; export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfers: Array<TokenTransfer> }>;
export type AddressChangedBytecode = SocketMessageParamsGeneric<'changed_bytecode', Record<string, never>>; export type AddressChangedBytecode = SocketMessageParamsGeneric<'changed_bytecode', Record<string, never>>;
export type AddressFetchedBytecode = SocketMessageParamsGeneric<'fetched_bytecode', { fetched_bytecode: string }>;
export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record<string, never>>; export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record<string, never>>;
export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>; export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>;
export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>; export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>;
......
import type { UseAccountReturnType } from 'wagmi';
import { useAccount } from 'wagmi';
import config from 'configs/app';
function useAccountFallback(): UseAccountReturnType {
return {
address: undefined,
addresses: undefined,
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
};
}
const hook = config.features.blockchainInteraction.isEnabled ? useAccount : useAccountFallback;
export default hook;
...@@ -30,6 +30,24 @@ export const withEns: AddressParam = { ...@@ -30,6 +30,24 @@ export const withEns: AddressParam = {
ens_domain_name: 'kitty.kitty.kitty.cat.eth', ens_domain_name: 'kitty.kitty.kitty.cat.eth',
}; };
export const withNameTag: AddressParam = {
hash: hash,
implementation_name: null,
is_contract: false,
is_verified: null,
name: 'ArianeeStore',
private_tags: [],
watchlist_names: [],
public_tags: [],
ens_domain_name: 'kitty.kitty.kitty.cat.eth',
metadata: {
reputation: null,
tags: [
{ tagType: 'name', name: 'Mrs. Duckie', slug: 'mrs-duckie', ordinal: 0, meta: null },
],
},
};
export const withoutName: AddressParam = { export const withoutName: AddressParam = {
hash: hash, hash: hash,
implementation_name: null, implementation_name: null,
......
import type { AddressTabsCounters } from 'types/api/address';
export const base: AddressTabsCounters = {
internal_txs_count: 13,
logs_count: 51,
token_balances_count: 3,
token_transfers_count: 3,
transactions_count: 51,
validations_count: 42,
withdrawals_count: 11,
};
import type { import type {
SmartContractQueryMethodReadError, SmartContractQueryMethodError,
SmartContractQueryMethodReadSuccess, SmartContractQueryMethodSuccess,
SmartContractReadMethod, SmartContractReadMethod,
SmartContractWriteMethod, SmartContractWriteMethod,
} from 'types/api/contract'; } from 'types/api/contract';
...@@ -94,7 +94,7 @@ export const read: Array<SmartContractReadMethod> = [ ...@@ -94,7 +94,7 @@ export const read: Array<SmartContractReadMethod> = [
}, },
]; ];
export const readResultSuccess: SmartContractQueryMethodReadSuccess = { export const readResultSuccess: SmartContractQueryMethodSuccess = {
is_error: false, is_error: false,
result: { result: {
names: [ 'amount' ], names: [ 'amount' ],
...@@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = { ...@@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
}, },
}; };
export const readResultError: SmartContractQueryMethodReadError = { export const readResultError: SmartContractQueryMethodError = {
is_error: true, is_error: true,
result: { result: {
message: 'Some shit happened', message: 'Some shit happened',
......
export const data = { import type { OptimisticL2WithdrawalsResponse } from 'types/api/optimisticL2';
export const data: OptimisticL2WithdrawalsResponse = {
items: [ items: [
{ {
challenge_period_end: null, challenge_period_end: null,
...@@ -11,12 +13,12 @@ export const data = { ...@@ -11,12 +13,12 @@ export const data = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
l1_tx_hash: '0x1a235bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136dca684', l1_tx_hash: '0x1a235bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136dca684',
l2_timestamp: '2022-02-15T12:50:02.000000Z', l2_timestamp: '2022-02-15T12:50:02.000000Z',
l2_tx_hash: '0x918cd8c5c24c17e06cd02b0379510c4ad56324bf153578fb9caaaa2fe4e7dc35', l2_tx_hash: '0x918cd8c5c24c17e06cd02b0379510c4ad56324bf153578fb9caaaa2fe4e7dc35',
msg_nonce: 396, msg_nonce: 396,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620172',
msg_nonce_version: 1, msg_nonce_version: 1,
status: 'Ready to prove', status: 'Ready to prove',
}, },
...@@ -27,7 +29,6 @@ export const data = { ...@@ -27,7 +29,6 @@ export const data = {
l2_timestamp: null, l2_timestamp: null,
l2_tx_hash: '0x2f117bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136def593', l2_tx_hash: '0x2f117bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136def593',
msg_nonce: 391, msg_nonce: 391,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620167',
msg_nonce_version: 1, msg_nonce_version: 1,
status: 'Ready to prove', status: 'Ready to prove',
}, },
...@@ -38,7 +39,6 @@ export const data = { ...@@ -38,7 +39,6 @@ export const data = {
l2_timestamp: null, l2_timestamp: null,
l2_tx_hash: '0xe14b1f46838176702244a5343629bcecf728ca2d9881d47b4ce46e00c387d7e3', l2_tx_hash: '0xe14b1f46838176702244a5343629bcecf728ca2d9881d47b4ce46e00c387d7e3',
msg_nonce: 390, msg_nonce: 390,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620166',
msg_nonce_version: 1, msg_nonce_version: 1,
status: 'Ready for relay', status: 'Ready for relay',
}, },
......
import type { AddressMetadataInfo, AddressMetadataTag } from 'types/api/addressMetadata'; /* eslint-disable max-len */
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
import { hash } from '../address/address'; export const nameTag: AddressMetadataTagApi = {
slug: 'quack-quack',
export const nameTag1: AddressMetadataTag = { name: 'Quack quack',
slug: 'ethermineru',
name: 'Ethermine.ru',
tagType: 'name', tagType: 'name',
ordinal: 0, ordinal: 99,
meta: null, meta: null,
}; };
export const genericTag1: AddressMetadataTag = { export const customNameTag: AddressMetadataTagApi = {
slug: 'ethermine.ru', slug: 'unicorn-uproar',
name: 'Ethermine.ru', name: 'Unicorn Uproar',
tagType: 'name',
ordinal: 777,
meta: {
tagUrl: 'https://example.com',
bgColor: 'linear-gradient(45deg, deeppink, deepskyblue)',
textColor: '#FFFFFF',
},
};
export const genericTag: AddressMetadataTagApi = {
slug: 'duck-owner',
name: 'duck owner 🦆',
tagType: 'generic', tagType: 'generic',
ordinal: 0, ordinal: 55,
meta: null, meta: {
bgColor: 'rgba(255,243,12,90%)',
},
}; };
export const protocolTag1: AddressMetadataTag = { export const infoTagWithLink: AddressMetadataTagApi = {
slug: 'goosegang',
name: 'GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG',
tagType: 'classifier',
ordinal: 11,
meta: {
tagUrl: 'https://example.com',
},
};
export const tagWithTooltip: AddressMetadataTagApi = {
slug: 'blockscout-heroes',
name: 'BlockscoutHeroes',
tagType: 'classifier',
ordinal: 42,
meta: {
tooltipDescription: 'The Blockscout team, EVM blockchain aficionados, illuminate Ethereum networks with unparalleled insight and prowess, leading the way in blockchain exploration! 🚀🔎',
tooltipIcon: 'https://localhost:3100/icon.svg',
tooltipTitle: 'Blockscout team member',
tooltipUrl: 'https://blockscout.com',
},
};
export const protocolTag: AddressMetadataTagApi = {
slug: 'aerodrome', slug: 'aerodrome',
name: 'Aerodrome', name: 'Aerodrome',
tagType: 'protocol', tagType: 'protocol',
ordinal: 0, ordinal: 0,
meta: null, meta: null,
}; };
export const baseInfo: AddressMetadataInfo = {
addresses: {
[hash]: {
tags: [ nameTag1, genericTag1, protocolTag1 ],
reputation: null,
},
},
};
export const data = { import type { AddressParam } from 'types/api/addressParams';
import type { WithdrawalsResponse } from 'types/api/withdrawals';
export const data: WithdrawalsResponse = {
items: [ items: [
{ {
amount: '192175000000000', amount: '192175000000000',
...@@ -10,7 +13,7 @@ export const data = { ...@@ -10,7 +13,7 @@ export const data = {
is_contract: false, is_contract: false,
is_verified: null, is_verified: null,
name: null, name: null,
}, } as AddressParam,
timestamp: '2022-06-07T18:12:24.000000Z', timestamp: '2022-06-07T18:12:24.000000Z',
validator_index: 49622, validator_index: 49622,
}, },
...@@ -24,7 +27,7 @@ export const data = { ...@@ -24,7 +27,7 @@ export const data = {
is_contract: false, is_contract: false,
is_verified: null, is_verified: null,
name: null, name: null,
}, } as AddressParam,
timestamp: '2022-05-07T18:12:24.000000Z', timestamp: '2022-05-07T18:12:24.000000Z',
validator_index: 49621, validator_index: 49621,
}, },
...@@ -38,7 +41,7 @@ export const data = { ...@@ -38,7 +41,7 @@ export const data = {
is_contract: false, is_contract: false,
is_verified: null, is_verified: null,
name: null, name: null,
}, } as AddressParam,
timestamp: '2022-04-07T18:12:24.000000Z', timestamp: '2022-04-07T18:12:24.000000Z',
validator_index: 49620, validator_index: 49620,
}, },
......
...@@ -10,6 +10,7 @@ const headers = require('./nextjs/headers'); ...@@ -10,6 +10,7 @@ const headers = require('./nextjs/headers');
const redirects = require('./nextjs/redirects'); const redirects = require('./nextjs/redirects');
const rewrites = require('./nextjs/rewrites'); const rewrites = require('./nextjs/rewrites');
/** @type {import('next').NextConfig} */
const moduleExports = { const moduleExports = {
transpilePackages: [ transpilePackages: [
'react-syntax-highlighter', 'react-syntax-highlighter',
...@@ -46,6 +47,14 @@ const moduleExports = { ...@@ -46,6 +47,14 @@ const moduleExports = {
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
experimental: { experimental: {
instrumentationHook: true, instrumentationHook: true,
turbo: {
rules: {
'*.svg': {
loaders: [ '@svgr/webpack' ],
as: '*.js',
},
},
},
}, },
}; };
......
...@@ -18,6 +18,7 @@ import theme from 'theme'; ...@@ -18,6 +18,7 @@ import theme from 'theme';
export type Props = { export type Props = {
children: React.ReactNode; children: React.ReactNode;
withSocket?: boolean; withSocket?: boolean;
withWalletClient?: boolean;
appContext?: { appContext?: {
pageProps: PageProps; pageProps: PageProps;
}; };
...@@ -47,7 +48,20 @@ const wagmiConfig = createConfig({ ...@@ -47,7 +48,20 @@ const wagmiConfig = createConfig({
}, },
}); });
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => { const WalletClientProvider = ({ children, withWalletClient }: { children: React.ReactNode; withWalletClient?: boolean }) => {
if (withWalletClient) {
return (
<WagmiProvider config={ wagmiConfig }>
{ children }
</WagmiProvider>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
const TestApp = ({ children, withSocket, withWalletClient = true, appContext = defaultAppContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({ const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
...@@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props ...@@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }> <SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }>
<AppContextProvider { ...appContext }> <AppContextProvider { ...appContext }>
<GrowthBookProvider> <GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }> <WalletClientProvider withWalletClient={ withWalletClient }>
{ children } { children }
</WagmiProvider> </WalletClientProvider>
</GrowthBookProvider> </GrowthBookProvider>
</AppContextProvider> </AppContextProvider>
</SocketProvider> </SocketProvider>
......
...@@ -16,6 +16,11 @@ const fixture: TestFixture<MockEnvsFixture, { page: Page }> = async({ page }, us ...@@ -16,6 +16,11 @@ const fixture: TestFixture<MockEnvsFixture, { page: Page }> = async({ page }, us
export default fixture; export default fixture;
export const ENVS_MAP: Record<string, Array<[string, string]>> = { export const ENVS_MAP: Record<string, Array<[string, string]>> = {
optimisticRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'optimistic' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
[ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ],
],
shibariumRollup: [ shibariumRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ], [ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ], [ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
...@@ -24,6 +29,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -24,6 +29,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ], [ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ], [ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
], ],
zkSyncRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkSync' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
],
bridgedTokens: [ bridgedTokens: [
[ 'NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS', '[{"id":"1","title":"Ethereum","short_title":"ETH","base_url":"https://eth.blockscout.com/token/"},{"id":"56","title":"Binance Smart Chain","short_title":"BSC","base_url":"https://bscscan.com/token/"},{"id":"99","title":"POA","short_title":"POA","base_url":"https://blockscout.com/poa/core/token/"}]' ], [ 'NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS', '[{"id":"1","title":"Ethereum","short_title":"ETH","base_url":"https://eth.blockscout.com/token/"},{"id":"56","title":"Binance Smart Chain","short_title":"BSC","base_url":"https://bscscan.com/token/"},{"id":"99","title":"POA","short_title":"POA","base_url":"https://blockscout.com/poa/core/token/"}]' ],
[ 'NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES', '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]' ], [ 'NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES', '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]' ],
...@@ -37,4 +46,18 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -37,4 +46,18 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
blockHiddenFields: [ blockHiddenFields: [
[ 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', '["burnt_fees", "total_reward", "nonce"]' ], [ 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', '["burnt_fees", "total_reward", "nonce"]' ],
], ],
stabilityEnvs: [
[ 'NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS', '["top_accounts"]' ],
[ 'NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS', '["value","fee_currency","gas_price","gas_fees","burnt_fees"]' ],
[ 'NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS', '["fee_per_gas"]' ],
],
beaconChain: [
[ 'NEXT_PUBLIC_HAS_BEACON_CHAIN', 'true' ],
],
txInterpretation: [
[ 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', 'blockscout' ],
],
noWalletClient: [
[ 'NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID', '' ],
],
}; };
...@@ -71,6 +71,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block ...@@ -71,6 +71,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'total_supply', payload: { total_supply: number}): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'total_supply', payload: { total_supply: number}): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_bytecode', payload: Record<string, never>): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_bytecode', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_bytecode', payload: { fetched_bytecode: string }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void { export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
......
...@@ -10,45 +10,3 @@ export const viewport = { ...@@ -10,45 +10,3 @@ export const viewport = {
export const maskColor = '#4299E1'; // blue.400 export const maskColor = '#4299E1'; // blue.400
export const adsBannerSelector = '.adsbyslise'; export const adsBannerSelector = '.adsbyslise';
export const featureEnvs = {
beaconChain: [
{ name: 'NEXT_PUBLIC_HAS_BEACON_CHAIN', value: 'true' },
],
optimisticRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'optimistic' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
{ name: 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', value: 'https://localhost:3102' },
],
txInterpretation: [
{ name: 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', value: 'blockscout' },
],
zkEvmRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'zkEvm' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
],
zkSyncRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'zkSync' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
],
userOps: [
{ name: 'NEXT_PUBLIC_HAS_USER_OPS', value: 'true' },
],
validators: [
{ name: 'NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE', value: 'stability' },
],
};
export const viewsEnvs = {
block: {
hiddenFields: [
{ name: 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', value: '["burnt_fees", "total_reward", "nonce"]' },
],
},
};
export const stabilityEnvs = [
{ name: 'NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS', value: '["top_accounts"]' },
{ name: 'NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS', value: '["value","fee_currency","gas_price","gas_fees","burnt_fees"]' },
{ name: 'NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS', value: '["fee_per_gas"]' },
];
...@@ -28,5 +28,5 @@ dotenv \ ...@@ -28,5 +28,5 @@ dotenv \
-v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \
-e $config_file \ -e $config_file \
-e $secrets_file \ -e $secrets_file \
-- bash -c './deploy/scripts/make_envs_script.sh && next dev -- -p $NEXT_PUBLIC_APP_PORT' | -- bash -c './deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT --turbo' |
pino-pretty pino-pretty
\ No newline at end of file
...@@ -7,6 +7,7 @@ export interface AddressMetadataInfo { ...@@ -7,6 +7,7 @@ export interface AddressMetadataInfo {
export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol'; export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol';
// Response model from Metadata microservice API
export interface AddressMetadataTag { export interface AddressMetadataTag {
slug: string; slug: string;
name: string; name: string;
...@@ -14,3 +15,20 @@ export interface AddressMetadataTag { ...@@ -14,3 +15,20 @@ export interface AddressMetadataTag {
ordinal: number; ordinal: number;
meta: string | null; meta: string | null;
} }
// Response model from Blockscout API with parsed meta field
export interface AddressMetadataTagApi extends Omit<AddressMetadataTag, 'meta'> {
meta: {
textColor?: string;
bgColor?: string;
tagUrl?: string;
tooltipIcon?: string;
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
actionURL?: string;
appID?: string;
logoURL?: string;
text?: string;
} | null;
}
import type { AddressMetadataTagApi } from './addressMetadata';
export interface AddressTag { export interface AddressTag {
label: string; label: string;
display_name: string; display_name: string;
...@@ -22,6 +24,10 @@ export type AddressParamBasic = { ...@@ -22,6 +24,10 @@ export type AddressParamBasic = {
is_contract: boolean; is_contract: boolean;
is_verified: boolean | null; is_verified: boolean | null;
ens_domain_name: string | null; ens_domain_name: string | null;
metadata?: {
reputation: number | null;
tags: Array<AddressMetadataTagApi>;
} | null;
} }
export type AddressParam = UserTags & AddressParamBasic; export type AddressParam = UserTags & AddressParamBasic;
import type { Abi, AbiType } from 'abitype'; import type { Abi, AbiType, AbiFallback, AbiFunction, AbiReceive } from 'abitype';
export type SmartContractMethodArgType = AbiType; export type SmartContractMethodArgType = AbiType;
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable'; export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
...@@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary { ...@@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary {
name: string; name: string;
} }
export interface SmartContractMethodBase { export type SmartContractMethodOutputValue = string | boolean | object;
inputs: Array<SmartContractMethodInput>; export type SmartContractMethodOutput = AbiFunction['outputs'][number] & { value?: SmartContractMethodOutputValue };
outputs?: Array<SmartContractMethodOutput>; export type SmartContractMethodBase = Omit<AbiFunction, 'outputs'> & {
constant: boolean;
name: string;
stateMutability: SmartContractMethodStateMutability;
type: 'function';
payable: boolean;
error?: string;
method_id: string; method_id: string;
} outputs: Array<SmartContractMethodOutput>;
constant?: boolean;
error?: string;
};
export type SmartContractReadMethod = SmartContractMethodBase; export type SmartContractReadMethod = SmartContractMethodBase;
export type SmartContractWriteMethod = SmartContractMethodBase | AbiFallback | AbiReceive;
export interface SmartContractWriteFallback {
payable?: true;
stateMutability: 'payable';
type: 'fallback';
}
export interface SmartContractWriteReceive {
payable?: true;
stateMutability: 'payable';
type: 'receive';
}
export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWriteFallback | SmartContractWriteReceive;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod; export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput { export interface SmartContractQueryMethodSuccess {
internalType?: string; // there could be any string, e.g "enum MyEnum"
name: string;
type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>;
fieldType?: 'native_coin';
}
export interface SmartContractMethodOutput extends SmartContractMethodInput {
value?: string | boolean | object;
}
export interface SmartContractQueryMethodReadSuccess {
is_error: false; is_error: false;
result: { result: {
names: Array<string | [ string, Array<string> ]>; names: Array<string | [ string, Array<string> ]>;
...@@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess { ...@@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess {
}; };
} }
export interface SmartContractQueryMethodReadError { export interface SmartContractQueryMethodError {
is_error: true; is_error: true;
result: { result: {
code: number; code: number;
...@@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError { ...@@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError {
}; };
} }
export type SmartContractQueryMethodRead = SmartContractQueryMethodReadSuccess | SmartContractQueryMethodReadError; export type SmartContractQueryMethod = SmartContractQueryMethodSuccess | SmartContractQueryMethodError;
// VERIFICATION // VERIFICATION
......
import type { AddressMetadataTagType } from 'types/api/addressMetadata'; import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
export interface AddressMetadataInfoFormatted { export interface AddressMetadataInfoFormatted {
addresses: Record<string, { addresses: Record<string, {
...@@ -7,21 +7,4 @@ export interface AddressMetadataInfoFormatted { ...@@ -7,21 +7,4 @@ export interface AddressMetadataInfoFormatted {
}>; }>;
} }
export interface AddressMetadataTagFormatted { export type AddressMetadataTagFormatted = AddressMetadataTagApi;
slug: string;
name: string;
tagType: AddressMetadataTagType;
ordinal: number;
meta: {
textColor?: string;
bgColor?: string;
actionURL?: string;
appID?: string;
logoURL?: string;
text?: string;
tooltipIcon?: string;
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
} | null;
}
...@@ -5,6 +5,8 @@ export const BLOCK_FIELDS_IDS = [ ...@@ -5,6 +5,8 @@ export const BLOCK_FIELDS_IDS = [
'total_reward', 'total_reward',
'nonce', 'nonce',
'miner', 'miner',
'L1_status',
'batch',
] as const; ] as const;
export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>; export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>;
...@@ -7,6 +7,8 @@ export const TX_FIELDS_IDS = [ ...@@ -7,6 +7,8 @@ export const TX_FIELDS_IDS = [
'tx_fee', 'tx_fee',
'gas_fees', 'gas_fees',
'burnt_fees', 'burnt_fees',
'L1_status',
'batch',
] as const; ] as const;
export type TxFieldsId = ArrayElement<typeof TX_FIELDS_IDS>; export type TxFieldsId = ArrayElement<typeof TX_FIELDS_IDS>;
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractInfoMock from 'mocks/contract/info';
import * as contractMethodsMock from 'mocks/contract/methods';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import AddressContract from './AddressContract.pwstory';
const hash = addressMock.contract.hash;
test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('address', addressMock.contract, { pathParams: { hash } });
await mockApiResponse('contract', contractInfoMock.verified, { pathParams: { hash } });
await mockApiResponse('contract_methods_read', contractMethodsMock.read, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
await mockApiResponse('contract_methods_write', contractMethodsMock.write, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
});
test.describe('ABI functionality', () => {
test('read', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('read, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('write', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeEnabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeEnabled();
});
test('write, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeDisabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeDisabled();
});
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressContract from './AddressContract';
const AddressContractPwStory = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const addressQuery = useApiQuery('address', { pathParams: { hash } });
const { tabs } = useContractTabs(addressQuery.data, false);
return <AddressContract tabs={ tabs } shouldRender={ true } isLoading={ false }/>;
};
export default AddressContractPwStory;
...@@ -3,7 +3,6 @@ import React from 'react'; ...@@ -3,7 +3,6 @@ import React from 'react';
import type { RoutedSubTab } from 'ui/shared/Tabs/types'; import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props { interface Props {
tabs: Array<RoutedSubTab>; tabs: Array<RoutedSubTab>;
...@@ -16,21 +15,12 @@ const TAB_LIST_PROPS = { ...@@ -16,21 +15,12 @@ const TAB_LIST_PROPS = {
}; };
const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => { const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => {
const fallback = React.useCallback(() => {
const noProviderTabs = tabs.filter(({ id }) => id === 'contract_code' || id.startsWith('read_'));
return (
<RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
);
}, [ isLoading, tabs ]);
if (!shouldRender) { if (!shouldRender) {
return null; return null;
} }
return ( return (
<Web3ModalProvider fallback={ fallback }> <RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
</Web3ModalProvider>
); );
}; };
......
import { Accordion, Box, Flex, Link } from '@chakra-ui/react'; import { Accordion, Box, Flex, Link } from '@chakra-ui/react';
import _range from 'lodash/range'; import _range from 'lodash/range';
import React from 'react'; import React from 'react';
import { scroller } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract'; import type { MethodType, ContractAbi as TContractAbi } from './types';
import ContractMethodsAccordionItem from './ContractMethodsAccordionItem'; import ContractAbiItem from './ContractAbiItem';
import useFormSubmit from './useFormSubmit';
import useScrollToMethod from './useScrollToMethod';
interface Props<T extends SmartContractMethod> { interface Props {
data: Array<T>; data: TContractAbi;
addressHash?: string; addressHash: string;
renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string; tab: string;
methodType: MethodType;
} }
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => { const ContractAbi = ({ data, addressHash, tab, methodType }: Props) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []); const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
const [ id, setId ] = React.useState(0); const [ id, setId ] = React.useState(0);
React.useEffect(() => { useScrollToMethod(data, setExpandedSections);
const hash = window.location.hash.replace('#', '');
if (!hash) { const handleFormSubmit = useFormSubmit({ addressHash, tab });
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === hash);
if (index > -1) {
scroller.scrollTo(`method_${ hash }`, {
duration: 500,
smooth: true,
offset: -100,
});
setExpandedSections([ index ]);
}
}, [ data ]);
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => { const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue); setExpandedSections(newValue);
...@@ -73,14 +60,15 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address ...@@ -73,14 +60,15 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
</Flex> </Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }> <Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => ( { data.map((item, index) => (
<ContractMethodsAccordionItem <ContractAbiItem
key={ index } key={ index }
data={ item } data={ item }
id={ id } id={ id }
index={ index } index={ index }
addressHash={ addressHash } addressHash={ addressHash }
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
tab={ tab } tab={ tab }
onSubmit={ handleFormSubmit }
methodType={ methodType }
/> />
)) } )) }
</Accordion> </Accordion>
...@@ -88,4 +76,4 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address ...@@ -88,4 +76,4 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
); );
}; };
export default React.memo(ContractMethodsAccordion) as typeof ContractMethodsAccordion; export default React.memo(ContractAbi);
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react'; import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box, Flex, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll'; import { Element } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract'; import type { FormSubmitHandler, MethodType, ContractAbiItem as TContractAbiItem } from './types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import Hint from 'ui/shared/Hint'; import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
interface Props<T extends SmartContractMethod> { import ContractAbiItemConstant from './ContractAbiItemConstant';
data: T; import ContractMethodForm from './form/ContractMethodForm';
import { getElementName } from './useScrollToMethod';
interface Props {
data: TContractAbiItem;
index: number; index: number;
id: number; id: number;
addressHash?: string; addressHash: string;
renderContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string; tab: string;
onSubmit: FormSubmitHandler;
methodType: MethodType;
} }
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => { const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodType }: Props) => {
const url = React.useMemo(() => { const url = React.useMemo(() => {
if (!('method_id' in data)) { if (!('method_id' in data)) {
return ''; return '';
...@@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
onCopy(); onCopy();
}, [ onCopy ]); }, [ onCopy ]);
const handleCopyMethodIdClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
const content = (() => {
if ('error' in data && data.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ data.error }</Alert>;
}
const hasConstantOutputs = 'outputs' in data && data.outputs.some(({ value }) => value !== undefined && value !== null);
if (hasConstantOutputs) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ data.outputs.map((output, index) => <ContractAbiItemConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ data }
onSubmit={ onSubmit }
methodType={ methodType }
/>
);
})();
return ( return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}> <AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => ( { ({ isExpanded }) => (
<> <>
<Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }> <Element as="h2" name={ 'method_id' in data ? getElementName(data.method_id) : '' }>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer"> <AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
{ 'method_id' in data && ( { 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }> <Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
...@@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
the contract cannot receive Ether through regular transactions and throws an exception.` the contract cannot receive Ether through regular transactions and throws an exception.`
}/> }/>
) } ) }
{ 'method_id' in data && (
<>
<Tag>{ data.method_id }</Tag>
<CopyToClipboard text={ `${ data.name } (${ data.method_id })` } onClick={ handleCopyMethodIdClick }/>
</>
) }
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/> <AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton> </AccordionButton>
</Element> </Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)"> <AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) } { content }
</AccordionPanel> </AccordionPanel>
</> </>
) } ) }
...@@ -97,4 +139,4 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -97,4 +139,4 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
); );
}; };
export default React.memo(ContractMethodsAccordionItem); export default React.memo(ContractAbiItem);
...@@ -4,12 +4,14 @@ import type { ChangeEvent } from 'react'; ...@@ -4,12 +4,14 @@ import type { ChangeEvent } from 'react';
import React from 'react'; import React from 'react';
import { getAddress } from 'viem'; import { getAddress } from 'viem';
import type { SmartContractMethodOutput } from 'types/api/contract'; import type { ContractAbiItemOutput } from './types';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { matchInt } from './form/utils';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string { function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
switch (typeof value) { switch (typeof value) {
case 'string': case 'string':
...@@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint | ...@@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint |
} }
interface Props { interface Props {
data: SmartContractMethodOutput; data: ContractAbiItemOutput;
} }
const ContractMethodStatic = ({ data }: Props) => { const ContractAbiItemConstant = ({ data }: Props) => {
const [ value, setValue ] = React.useState<string>(castValueToString(data.value)); const [ value, setValue ] = React.useState<string>(castValueToString(data.value));
const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase()); const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase());
const intMatch = matchInt(data.type);
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => { const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const initialValue = castValueToString(data.value); const initialValue = castValueToString(data.value);
...@@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => { ...@@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => {
return ( return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }> <Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
{ content } { content }
{ (data.type.includes('int256') || data.type.includes('int128')) && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> } { Number(intMatch?.power) >= 128 && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex> </Flex>
); );
}; };
export default ContractMethodStatic; export default ContractAbiItemConstant;
...@@ -3,18 +3,18 @@ import React from 'react'; ...@@ -3,18 +3,18 @@ import React from 'react';
import { useController, useFormContext } from 'react-hook-form'; import { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import ClearButton from 'ui/shared/ClearButton'; import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton'; import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt';
import useFormatFieldValue from './useFormatFieldValue'; import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField'; import useValidateField from './useValidateField';
import { matchInt } from './utils';
interface Props { interface Props {
data: SmartContractMethodInput; data: ContractAbiItemInput;
hideLabel?: boolean; hideLabel?: boolean;
path: string; path: string;
className?: string; className?: string;
...@@ -28,7 +28,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -28,7 +28,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const isNativeCoin = data.fieldType === 'native_coin'; const isNativeCoin = data.fieldType === 'native_coin';
const isOptional = isNativeCoin; const isOptional = isNativeCoin;
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type }); const argTypeMatchInt = React.useMemo(() => matchInt(data.type), [ data.type ]);
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt }); const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt }); const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
......
...@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput, SmartContractMethodArgType } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import ContractMethodArrayButton from './ContractMethodArrayButton'; import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion'; import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
...@@ -10,21 +10,35 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; ...@@ -10,21 +10,35 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel } from './utils'; import { getFieldLabel, matchArray, transformDataForArrayItem } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> { interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput; data: ContractAbiItemInput;
level: number; level: number;
basePath: string; basePath: string;
isDisabled: boolean; isDisabled: boolean;
isArrayElement?: boolean;
size?: number;
} }
const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRemoveClick, index: parentIndex, isDisabled }: Props) => { const ContractMethodFieldInputArray = ({
data,
level,
basePath,
onAddClick,
onRemoveClick,
index: parentIndex,
isDisabled,
isArrayElement,
}: Props) => {
const { formState: { errors } } = useFormContext(); const { formState: { errors } } = useFormContext();
const fieldsWithErrors = Object.keys(errors); const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath)); const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
const [ registeredIndices, setRegisteredIndices ] = React.useState([ 0 ]); const arrayMatch = matchArray(data.type);
const hasFixedSize = arrayMatch !== null && arrayMatch.size !== Infinity;
const [ registeredIndices, setRegisteredIndices ] = React.useState(hasFixedSize ? Array(arrayMatch.size).fill(0).map((_, i) => i) : [ 0 ]);
const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault(); event.preventDefault();
...@@ -39,52 +53,69 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -39,52 +53,69 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
} }
}, [ ]); }, [ ]);
const getItemData = (index: number) => { if (arrayMatch?.isNested) {
const childrenType = data.type.slice(0, -2) as SmartContractMethodArgType; return (
const childrenInternalType = data.internalType?.slice(0, parentIndex !== undefined ? -4 : -2).replaceAll('struct ', ''); <>
{
const namePostfix = childrenInternalType ? ' ' + childrenInternalType : ''; registeredIndices.map((registeredIndex, index) => {
const nameParentIndex = parentIndex !== undefined ? `${ parentIndex + 1 }.` : ''; const itemData = transformDataForArrayItem(data, index);
const nameIndex = index + 1; const itemBasePath = `${ basePath }:${ registeredIndex }`;
const itemIsInvalid = fieldsWithErrors.some((field) => field.startsWith(itemBasePath));
return (
<ContractMethodFieldAccordion
key={ registeredIndex }
level={ level + 1 }
label={ getFieldLabel(itemData) }
isInvalid={ itemIsInvalid }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
>
<ContractMethodFieldInputArray
key={ registeredIndex }
data={ itemData }
basePath={ itemBasePath }
level={ level + 1 }
isDisabled={ isDisabled }
isArrayElement
/>
</ContractMethodFieldAccordion>
);
})
}
</>
);
}
return { const isTupleArray = arrayMatch?.itemType.includes('tuple');
...data,
type: childrenType,
name: `#${ nameParentIndex + nameIndex }${ namePostfix }`,
};
};
const isNestedArray = data.type.includes('[][]');
if (isNestedArray) { if (isTupleArray) {
return ( const content = (
<ContractMethodFieldAccordion <>
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => { { registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index); const itemData = transformDataForArrayItem(data, index);
return ( return (
<ContractMethodFieldInputArray <ContractMethodFieldInputTuple
key={ registeredIndex } key={ registeredIndex }
data={ itemData } data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` } basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 } level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined } onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined } onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex } index={ registeredIndex }
isDisabled={ isDisabled } isDisabled={ isDisabled }
/> />
); );
}) } }) }
</ContractMethodFieldAccordion> </>
); );
}
const isTupleArray = data.type.includes('tuple'); if (isArrayElement) {
return content;
}
if (isTupleArray) {
return ( return (
<ContractMethodFieldAccordion <ContractMethodFieldAccordion
level={ level } level={ level }
...@@ -94,22 +125,7 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -94,22 +125,7 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
index={ parentIndex } index={ parentIndex }
isInvalid={ isInvalid } isInvalid={ isInvalid }
> >
{ registeredIndices.map((registeredIndex, index) => { { content }
const itemData = getItemData(index);
return (
<ContractMethodFieldInputTuple
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion> </ContractMethodFieldAccordion>
); );
} }
...@@ -117,10 +133,10 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -117,10 +133,10 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
// primitive value array // primitive value array
return ( return (
<Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px"> <Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px">
<ContractMethodFieldLabel data={ data } level={ level }/> { !isArrayElement && <ContractMethodFieldLabel data={ data } level={ level }/> }
<Flex flexDir="column" rowGap={ 1 } w="100%"> <Flex flexDir="column" rowGap={ 1 } w="100%">
{ registeredIndices.map((registeredIndex, index) => { { registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index); const itemData = transformDataForArrayItem(data, index);
return ( return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }> <Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
...@@ -132,9 +148,9 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -132,9 +148,9 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
px={ 0 } px={ 0 }
isDisabled={ isDisabled } isDisabled={ isDisabled }
/> />
{ registeredIndices.length > 1 && { !hasFixedSize && registeredIndices.length > 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> } <ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> }
{ index === registeredIndices.length - 1 && { !hasFixedSize && index === registeredIndices.length - 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> } <ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> }
</Flex> </Flex>
); );
......
import React from 'react'; import React from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion'; import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import { ARRAY_REGEXP, getFieldLabel } from './utils'; import { getFieldLabel, matchArray } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> { interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput; data: ContractAbiItemInput;
basePath: string; basePath: string;
level: number; level: number;
isDisabled: boolean; isDisabled: boolean;
...@@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a ...@@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
const fieldsWithErrors = Object.keys(errors); const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath)); const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
if (!('components' in data)) {
return null;
}
return ( return (
<ContractMethodFieldAccordion <ContractMethodFieldAccordion
{ ...accordionProps } { ...accordionProps }
...@@ -29,7 +33,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a ...@@ -29,7 +33,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
isInvalid={ isInvalid } isInvalid={ isInvalid }
> >
{ data.components?.map((component, index) => { { data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') { if ('components' in component && component.type === 'tuple') {
return ( return (
<ContractMethodFieldInputTuple <ContractMethodFieldInputTuple
key={ index } key={ index }
...@@ -41,15 +45,14 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a ...@@ -41,15 +45,14 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
); );
} }
const arrayMatch = component.type.match(ARRAY_REGEXP); const arrayMatch = matchArray(component.type);
if (arrayMatch) { if (arrayMatch) {
const [ , itemType ] = arrayMatch;
return ( return (
<ContractMethodFieldInputArray <ContractMethodFieldInputArray
key={ index } key={ index }
data={ component } data={ component }
basePath={ `${ basePath }:${ index }` } basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level } level={ arrayMatch.itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled } isDisabled={ isDisabled }
/> />
); );
......
import { Box, useColorModeValue } from '@chakra-ui/react'; import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import { getFieldLabel } from './utils'; import { getFieldLabel } from './utils';
interface Props { interface Props {
data: SmartContractMethodInput; data: ContractAbiItemInput;
isOptional?: boolean; isOptional?: boolean;
level: number; level: number;
} }
......
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract'; import type { ContractAbiItem } from '../types';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import ContractMethodForm from './ContractMethodForm'; import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` }); const onSubmit = () => Promise.resolve({ source: 'wallet_client' as const, result: { hash: '0x0000' as `0x${ string }` } });
const resultComponent = () => null;
const data: SmartContractWriteMethod = { const data: ContractAbiItem = {
inputs: [ inputs: [
// TUPLE // TUPLE
{ {
...@@ -53,6 +52,13 @@ const data: SmartContractWriteMethod = { ...@@ -53,6 +52,13 @@ const data: SmartContractWriteMethod = {
type: 'tuple[][]', type: 'tuple[][]',
}, },
// TOP LEVEL NESTED ARRAY
{
internalType: 'int256[2][][3]',
name: 'ParentArray',
type: 'int256[2][][3]',
},
// LITERALS // LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' }, { internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' }, { internalType: 'address', name: 'recipient', type: 'address' },
...@@ -95,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => { ...@@ -95,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<ContractMethodForm<SmartContractWriteMethod> <ContractMethodForm
data={ data } data={ data }
onSubmit={ onSubmit } onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write" methodType="write"
/> />
</TestApp>, </TestApp>,
...@@ -125,9 +130,13 @@ test('base view +@mobile +@dark-mode', async({ mount }) => { ...@@ -125,9 +130,13 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
await component.getByText('struct FulfillmentComponent[][]').click(); await component.getByText('struct FulfillmentComponent[][]').click();
await component.getByRole('button', { name: 'add' }).nth(1).click(); await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('#1 FulfillmentComponent[]').click(); await component.getByText('#1 FulfillmentComponent[]').click();
await component.getByText('#1.1 FulfillmentComponent').click(); await component.getByLabel('#1 FulfillmentComponent[] (tuple[])').getByText('#1 FulfillmentComponent (tuple)').click();
await component.getByRole('button', { name: 'add' }).nth(1).click(); await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('ParentArray (int256[2][][3])').click();
await component.getByText('#1 int256[2][] (int256[2][])').click();
await component.getByLabel('#1 int256[2][] (int256[2][])').getByText('#1 int256[2] (int256[2])').click();
// submit form // submit form
await component.getByRole('button', { name: 'Write' }).click(); await component.getByRole('button', { name: 'Write' }).click();
......
import { Box, Button, Flex, chakra } from '@chakra-ui/react'; import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react';
import _mapValues from 'lodash/mapValues'; import _mapValues from 'lodash/mapValues';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { AbiFunction } from 'viem';
import type { ContractMethodCallResult } from '../types'; import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, MethodType, ContractAbiItem } from '../types';
import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFormOutputs from './ContractMethodFormOutputs'; import ContractMethodOutputs from './ContractMethodOutputs';
import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils'; import ContractMethodResult from './ContractMethodResult';
import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils';
import type { ContractMethodFormFields } from './utils'; import type { ContractMethodFormFields } from './utils';
interface Props<T extends SmartContractMethod> { interface Props {
data: T; data: ContractAbiItem;
onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>; onSubmit: FormSubmitHandler;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null; methodType: MethodType;
methodType: 'read' | 'write';
} }
const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props<T>) => { const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>(); const [ result, setResult ] = React.useState<FormSubmitResult>();
const [ isLoading, setLoading ] = React.useState(false); const [ isLoading, setLoading ] = React.useState(false);
const [ callStrategy, setCallStrategy ] = React.useState<MethodCallStrategy>();
const callStrategyRef = React.useRef(callStrategy);
const formApi = useForm<ContractMethodFormFields>({ const formApi = useForm<ContractMethodFormFields>({
mode: 'all', mode: 'all',
shouldUnregister: true, shouldUnregister: true,
}); });
const handleButtonClick = React.useCallback((event: React.MouseEvent) => {
const callStrategy = event?.currentTarget.getAttribute('data-call-strategy');
setCallStrategy(callStrategy as MethodCallStrategy);
callStrategyRef.current = callStrategy as MethodCallStrategy;
}, []);
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => { const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
// The API used for reading from contracts expects all values to be strings. // The API used for reading from contracts expects all values to be strings.
const formattedData = methodType === 'read' ? const formattedData = callStrategyRef.current === 'api' ?
_mapValues(formData, (value) => value !== undefined ? String(value) : undefined) : _mapValues(formData, (value) => value !== undefined ? String(value) : undefined) :
formData; formData;
const args = transformFormDataToMethodArgs(formattedData); const args = transformFormDataToMethodArgs(formattedData);
...@@ -45,12 +53,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -45,12 +53,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
onSubmit(data, args) onSubmit(data, args, callStrategyRef.current)
.then((result) => { .then((result) => {
setResult(result); setResult(result);
}) })
.catch((error) => { .catch((error) => {
setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error); setResult({
source: callStrategyRef.current ?? 'wallet_client',
result: error?.error || error?.data || (error?.reason && { message: error.reason }) || error,
});
setLoading(false); setLoading(false);
}) })
.finally(() => { .finally(() => {
...@@ -69,9 +80,9 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -69,9 +80,9 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
result && setResult(undefined); result && setResult(undefined);
}, [ result ]); }, [ result ]);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => { const inputs: AbiFunction['inputs'] = React.useMemo(() => {
return [ return [
...('inputs' in data ? data.inputs : []), ...('inputs' in data && data.inputs ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ { ...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`, name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const, type: 'uint256' as const,
...@@ -83,6 +94,28 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -83,6 +94,28 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
const outputs = 'outputs' in data && data.outputs ? data.outputs : []; const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
const callStrategies = (() => {
switch (methodType) {
case 'read': {
return { primary: 'api', secondary: undefined };
}
case 'write': {
return {
primary: config.features.blockchainInteraction.isEnabled ? 'wallet_client' : undefined,
secondary: 'outputs' in data && Boolean(data.outputs?.length) ? 'api' : undefined,
};
}
default: {
return { primary: undefined, secondary: undefined };
}
}
})();
// eslint-disable-next-line max-len
const noWalletClientText = 'Blockchain interaction is not available at the moment since WalletConnect is not configured for this application. Please contact the service maintainer to make necessary changes in the service configuration.';
return ( return (
<Box> <Box>
<FormProvider { ...formApi }> <FormProvider { ...formApi }>
...@@ -93,34 +126,81 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -93,34 +126,81 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
> >
<Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}> <Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
{ inputs.map((input, index) => { { inputs.map((input, index) => {
if (input.components && input.type === 'tuple') { const props = {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>; data: input,
basePath: `${ index }`,
isDisabled: isLoading,
level: 0,
};
if ('components' in input && input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } { ...props }/>;
} }
const arrayMatch = input.type.match(ARRAY_REGEXP); const arrayMatch = matchArray(input.type);
if (arrayMatch) { if (arrayMatch) {
return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>; if (arrayMatch.isNested) {
const fieldsWithErrors = Object.keys(formApi.formState.errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(index + ':'));
return (
<ContractMethodFieldAccordion
key={ index }
level={ 0 }
label={ getFieldLabel(input) }
isInvalid={ isInvalid }
>
<ContractMethodFieldInputArray { ...props }/>
</ContractMethodFieldAccordion>
);
}
return <ContractMethodFieldInputArray key={ index } { ...props }/>;
} }
return <ContractMethodFieldInput key={ index } data={ input } path={ `${ index }` } isDisabled={ isLoading } level={ 0 }/>; return <ContractMethodFieldInput key={ index } { ...props } path={ `${ index }` }/>;
}) } }) }
</Flex> </Flex>
<Button { callStrategies.secondary && (
isLoading={ isLoading } <Button
loadingText={ methodType === 'write' ? 'Write' : 'Read' } isLoading={ callStrategy === callStrategies.secondary && isLoading }
variant="outline" isDisabled={ isLoading }
size="sm" onClick={ handleButtonClick }
flexShrink={ 0 } loadingText="Simulate"
width="min-content" variant="outline"
px={ 4 } size="sm"
type="submit" flexShrink={ 0 }
> width="min-content"
{ methodType === 'write' ? 'Write' : 'Read' } px={ 4 }
</Button> mr={ 3 }
type="submit"
data-call-strategy={ callStrategies.secondary }
>
Simulate
</Button>
) }
<Tooltip label={ !callStrategies.primary ? noWalletClientText : undefined } maxW="300px">
<Button
isLoading={ callStrategy === callStrategies.primary && isLoading }
isDisabled={ isLoading || !callStrategies.primary }
onClick={ handleButtonClick }
loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
data-call-strategy={ callStrategies.primary }
>
{ methodType === 'write' ? 'Write' : 'Read' }
</Button>
</Tooltip>
</chakra.form> </chakra.form>
</FormProvider> </FormProvider>
{ methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> } { 'outputs' in data && Boolean(data.outputs?.length) && <ContractMethodOutputs data={ outputs }/> }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> } { result && <ContractMethodResult abiItem={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box> </Box>
); );
}; };
......
import { Flex, chakra } from '@chakra-ui/react'; import { Flex, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { AbiFunction } from 'viem';
import type { SmartContractMethodOutput } from 'types/api/contract';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
interface Props { interface Props {
data: Array<SmartContractMethodOutput>; data: AbiFunction['outputs'];
} }
const ContractMethodFormOutputs = ({ data }: Props) => { const ContractMethodOutputs = ({ data }: Props) => {
if (data.length === 0) { if (data.length === 0) {
return null; return null;
} }
...@@ -32,4 +31,4 @@ const ContractMethodFormOutputs = ({ data }: Props) => { ...@@ -32,4 +31,4 @@ const ContractMethodFormOutputs = ({ data }: Props) => {
); );
}; };
export default React.memo(ContractMethodFormOutputs); export default React.memo(ContractMethodOutputs);
import React from 'react';
import type { FormSubmitResult, ContractAbiItem } from '../types';
import ContractMethodResultApi from './ContractMethodResultApi';
import ContractMethodResultWalletClient from './ContractMethodResultWalletClient';
interface Props {
abiItem: ContractAbiItem;
result: FormSubmitResult;
onSettle: () => void;
}
const ContractMethodResult = ({ result, abiItem, onSettle }: Props) => {
switch (result.source) {
case 'api':
return <ContractMethodResultApi item={ abiItem } result={ result.result } onSettle={ onSettle }/>;
case 'wallet_client':
return <ContractMethodResultWalletClient result={ result.result } onSettle={ onSettle }/>;
default: {
return null;
}
}
};
export default React.memo(ContractMethodResult);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import type { ContractMethodReadResult } from './types'; import type { FormSubmitResultApi } from '../types';
import * as contractMethodsMock from 'mocks/contract/methods'; import * as contractMethodsMock from 'mocks/contract/methods';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import ContractReadResult from './ContractReadResult'; import ContractMethodResultApi from './ContractMethodResultApi';
const item = contractMethodsMock.read[0]; const item = contractMethodsMock.read[0];
const onSettle = () => Promise.resolve(); const onSettle = () => Promise.resolve();
test.use({ viewport: { width: 500, height: 500 } }); test.use({ viewport: { width: 500, height: 500 } });
test('default error', async({ mount }) => { test('default error', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
error: 'I am an error', error: 'I am an error',
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('error with code', async({ mount }) => { test('error with code', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
message: 'I am an error', message: 'I am an error',
code: -32017, code: -32017,
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('raw error', async({ mount }) => { test('raw error', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72', raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72',
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('complex error', async({ mount }) => { test('complex error', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
method_call: 'SomeCustomError(address addr, uint256 balance)', method_call: 'SomeCustomError(address addr, uint256 balance)',
...@@ -74,34 +61,26 @@ test('complex error', async({ mount }) => { ...@@ -74,34 +61,26 @@ test('complex error', async({ mount }) => {
], ],
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('success', async({ mount }) => { test('success', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: false, is_error: false,
result: { result: {
names: [ 'address' ], names: [ 'address' ],
output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ], output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ],
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('complex success', async({ mount }) => { test('complex success', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: false, is_error: false,
result: { result: {
names: [ names: [
...@@ -122,11 +101,7 @@ test('complex success', async({ mount }) => { ...@@ -122,11 +101,7 @@ test('complex success', async({ mount }) => {
], ],
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractAbiItem, FormSubmitResultApi } from '../types';
import hexToUtf8 from 'lib/hexToUtf8';
import ContractMethodResultApiError from './ContractMethodResultApiError';
import ContractMethodResultApiItem from './ContractMethodResultApiItem';
interface Props {
item: ContractAbiItem;
result: FormSubmitResultApi['result'];
onSettle: () => void;
}
const ContractMethodResultApi = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractMethodResultApiError>{ result.statusText }</ContractMethodResultApiError>;
}
if (result instanceof Error) {
return <ContractMethodResultApiError>{ result.message }</ContractMethodResultApiError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractMethodResultApiError>{ result.result.error }</ContractMethodResultApiError>;
}
if ('message' in result.result) {
return <ContractMethodResultApiError>[{ result.result.code }] { result.result.message }</ContractMethodResultApiError>;
}
if ('raw' in result.result) {
return <ContractMethodResultApiError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractMethodResultApiError>;
}
if ('method_id' in result.result) {
return <ContractMethodResultApiError>{ JSON.stringify(result.result, undefined, 2) }</ContractMethodResultApiError>;
}
return <ContractMethodResultApiError>Something went wrong.</ContractMethodResultApiError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractMethodResultApiItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractMethodResultApi);
import { Alert } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const ContractMethodResultApiError = ({ children }: Props) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</Alert>
);
};
export default React.memo(ContractMethodResultApiError);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractQueryMethodSuccess } from 'types/api/contract';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
interface Props {
output: SmartContractQueryMethodSuccess['result']['output'][0];
name: SmartContractQueryMethodSuccess['result']['names'][0];
}
const ContractMethodResultApiItem = ({ output, name }: Props) => {
if (Array.isArray(name)) {
const [ structName, argNames ] = name;
const argTypes = output.type.match(TUPLE_TYPE_REGEX)?.[1].split(',');
return (
<>
<p>
<chakra.span fontWeight={ 500 }> { structName }</chakra.span>
<span> ({ output.type }) :</span>
</p>
{ argNames.map((argName, argIndex) => {
return (
<p key={ argName }>
<chakra.span fontWeight={ 500 }> { argName }</chakra.span>
<span>{ argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) }</span>
</p>
);
}) }
</>
);
}
return (
<p>
<span> </span>
{ name && <chakra.span fontWeight={ 500 }>{ name } </chakra.span> }
<span>({ output.type }) : { String(output.value) }</span>
</p>
);
};
export default React.memo(ContractMethodResultApiItem);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import ContractWriteResultDumb from './ContractWriteResultDumb'; import type { PropsDumb } from './ContractMethodResultWalletClient';
import { ContractMethodResultWalletClientDumb } from './ContractMethodResultWalletClient';
test('loading', async({ mount }) => { test('loading', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'pending' as const, status: 'pending' as const,
error: null, error: null,
}, } as PropsDumb['txInfo'],
result: { result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
}, },
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('success', async({ mount }) => { test('success', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'success' as const, status: 'success' as const,
error: null, error: null,
}, } as PropsDumb['txInfo'],
result: { result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
}, },
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('error +@mobile', async({ mount }) => { test('error +@mobile', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'error' as const, status: 'error' as const,
...@@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => { ...@@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
message: 'missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ]', message: 'missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ]',
} as Error, } as Error,
}, } as PropsDumb['txInfo'],
result: { result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
}, },
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('error in result', async({ mount }) => { test('error in result', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'idle' as const, status: 'idle' as const,
error: null, error: null,
}, } as unknown as PropsDumb['txInfo'],
result: { result: {
message: 'wallet is not connected', message: 'wallet is not connected',
} as Error, } as Error,
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { Box, chakra, Spinner } from '@chakra-ui/react'; import { chakra, Spinner, Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { UseWaitForTransactionReceiptReturnType } from 'wagmi';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ContractMethodWriteResult } from './types'; import type { FormSubmitResultWalletClient } from '../types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
interface Props { interface Props {
result: ContractMethodWriteResult; result: FormSubmitResultWalletClient['result'];
onSettle: () => void; onSettle: () => void;
txInfo: {
status: 'loading' | 'success' | 'error' | 'idle' | 'pending';
error: Error | null;
};
} }
const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { const ContractMethodResultWalletClient = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractMethodResultWalletClientDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export interface PropsDumb {
result: FormSubmitResultWalletClient['result'];
onSettle: () => void;
txInfo: UseWaitForTransactionReceiptReturnType;
}
export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo }: PropsDumb) => {
const txHash = result && 'hash' in result ? result.hash : undefined; const txHash = result && 'hash' in result ? result.hash : undefined;
React.useEffect(() => { React.useEffect(() => {
...@@ -93,4 +106,4 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { ...@@ -93,4 +106,4 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
); );
}; };
export default React.memo(ContractWriteResultDumb); export default React.memo(ContractMethodResultWalletClient);
import React from 'react'; import React from 'react';
import type { SmartContractMethodArgType } from 'types/api/contract'; import type { MatchInt } from './utils';
import type { MatchInt } from './useArgTypeMatchInt';
interface Params { interface Params {
argType: SmartContractMethodArgType; argType: string;
argTypeMatchInt: MatchInt | null; argTypeMatchInt: MatchInt | null;
} }
......
import React from 'react'; import React from 'react';
import { getAddress, isAddress, isHex } from 'viem'; import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract'; import type { MatchInt } from './utils';
import type { MatchInt } from './useArgTypeMatchInt';
import { BYTES_REGEXP } from './utils'; import { BYTES_REGEXP } from './utils';
interface Params { interface Params {
argType: SmartContractMethodArgType; argType: string;
argTypeMatchInt: MatchInt | null; argTypeMatchInt: MatchInt | null;
isOptional: boolean; isOptional: boolean;
} }
......
import _set from 'lodash/set'; import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
export type ContractMethodFormFields = Record<string, string | boolean | undefined>; export type ContractMethodFormFields = Record<string, string | boolean | undefined>;
...@@ -10,6 +10,62 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i; ...@@ -10,6 +10,62 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i;
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/; export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export interface MatchArray {
itemType: string;
size: number;
isNested: boolean;
}
export const matchArray = (argType: string): MatchArray | null => {
const match = argType.match(ARRAY_REGEXP);
if (!match) {
return null;
}
const [ , itemType, size ] = match;
const isNested = Boolean(matchArray(itemType));
return {
itemType,
size: size ? Number(size) : Infinity,
isNested,
};
};
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: bigint;
max: bigint;
}
export const matchInt = (argType: string): MatchInt | null => {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned: Boolean(isUnsigned), power, min, max };
};
export const transformDataForArrayItem = (data: ContractAbiItemInput, index: number): ContractAbiItemInput => {
const arrayMatchType = matchArray(data.type);
const arrayMatchInternalType = data.internalType ? matchArray(data.internalType) : null;
const childrenInternalType = arrayMatchInternalType?.itemType.replaceAll('struct ', '');
const postfix = childrenInternalType ? ' ' + childrenInternalType : '';
return {
...data,
type: arrayMatchType?.itemType || data.type,
internalType: childrenInternalType,
name: `#${ index + 1 }${ postfix }`,
};
};
export const getIntBoundaries = (power: number, isUnsigned: boolean) => { export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
const maxUnsigned = BigInt(2 ** power); const maxUnsigned = BigInt(2 ** power);
const max = isUnsigned ? maxUnsigned - BigInt(1) : maxUnsigned / BigInt(2) - BigInt(1); const max = isUnsigned ? maxUnsigned - BigInt(1) : maxUnsigned / BigInt(2) - BigInt(1);
...@@ -41,7 +97,7 @@ function filterOurEmptyItems(array: Array<unknown>): Array<unknown> { ...@@ -41,7 +97,7 @@ function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
.filter((item) => item !== undefined); .filter((item) => item !== undefined);
} }
export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) { export function getFieldLabel(input: ContractAbiItemInput, isRequired?: boolean) {
const name = input.name || input.internalType || '<unnamed argument>'; const name = input.name || input.internalType || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`; return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
} }
import type { AbiFunction } from 'abitype';
import type { SmartContractMethod, SmartContractMethodOutput, SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
export type ContractAbiItemInput = AbiFunction['inputs'][number] & { fieldType?: 'native_coin' };
export type ContractAbiItemOutput = SmartContractMethodOutput;
export type ContractAbiItem = SmartContractMethod;
export type ContractAbi = Array<ContractAbiItem>;
export type MethodType = 'read' | 'write';
export type MethodCallStrategy = 'api' | 'wallet_client';
export interface FormSubmitResultApi {
source: 'api';
result: SmartContractQueryMethod | ResourceError | Error;
}
export interface FormSubmitResultWalletClient {
source: 'wallet_client';
result: Error | { hash: `0x${ string }` | undefined } | undefined;
}
export type FormSubmitResult = FormSubmitResultApi | FormSubmitResultWalletClient;
export type FormSubmitHandler = (item: ContractAbiItem, args: Array<unknown>, submitType: MethodCallStrategy | undefined) => Promise<FormSubmitResult>;
import React from 'react';
import type { FormSubmitResult } from './types';
import type { SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useAccount from 'lib/web3/useAccount';
interface Params {
methodId: string;
args: Array<unknown>;
isProxy: boolean;
isCustomAbi: boolean;
addressHash: string;
}
export default function useCallMethodApi(): (params: Params) => Promise<FormSubmitResult> {
const apiFetch = useApiFetch();
const { address } = useAccount();
return React.useCallback(async({ addressHash, isCustomAbi, isProxy, args, methodId }) => {
try {
const response = await apiFetch<'contract_method_query', SmartContractQueryMethod>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: methodId,
contract_type: isProxy ? 'proxy' : 'regular',
from: address,
},
},
});
return {
source: 'api',
result: response,
};
} catch (error) {
return {
source: 'api',
result: error as (Error | ResourceError),
};
}
}, [ address, apiFetch ]);
}
import React from 'react';
import type { Abi } from 'viem';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { ContractAbiItem, FormSubmitResult } from './types';
import config from 'configs/app';
import { getNativeCoinValue } from './utils';
interface Params {
item: ContractAbiItem;
args: Array<unknown>;
addressHash: string;
}
export default function useCallMethodWalletClient(): (params: Params) => Promise<FormSubmitResult> {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
return React.useCallback(async({ args, item, addressHash }) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (!walletClient) {
throw new Error('Wallet Client is not defined');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { source: 'wallet_client', result: { hash } };
}
const methodName = item.name;
if (!methodName) {
throw new Error('Method name is not defined');
}
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const hash = await walletClient.writeContract({
args: _args,
// Here we provide the ABI as an array containing only one item from the submitted form.
// This is a workaround for the issue with the "viem" library.
// It lacks a "method_id" field to uniquely identify the correct method and instead attempts to find a method based on its name.
// But the name is not unique in the contract ABI and this behavior in the "viem" could result in calling the wrong method.
// See related issues:
// - https://github.com/blockscout/frontend/issues/1032,
// - https://github.com/blockscout/frontend/issues/1327
abi: [ item ] as Abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { source: 'wallet_client', result: { hash } };
}, [ chainId, isConnected, switchChainAsync, walletClient ]);
}
import React from 'react';
import type { FormSubmitHandler } from './types';
import config from 'configs/app';
import useCallMethodApi from './useCallMethodApi';
import useCallMethodWalletClient from './useCallMethodWalletClient';
interface Params {
tab: string;
addressHash: string;
}
function useFormSubmit({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
const callMethodWalletClient = useCallMethodWalletClient();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
case 'wallet_client': {
return callMethodWalletClient({ args, item, addressHash });
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, callMethodWalletClient, tab ]);
}
function useFormSubmitFallback({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, tab ]);
}
const hook = config.features.blockchainInteraction.isEnabled ? useFormSubmit : useFormSubmitFallback;
export default hook;
import React from 'react';
import { scroller } from 'react-scroll';
import type { ContractAbi } from './types';
export const getElementName = (id: string) => `method_${ id }`;
export default function useScrollToMethod(data: ContractAbi, onScroll: (indices: Array<number>) => void) {
React.useEffect(() => {
const id = window.location.hash.replace('#', '');
if (!id) {
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === id);
if (index > -1) {
scroller.scrollTo(getElementName(id), {
duration: 500,
smooth: true,
offset: -100,
});
onScroll([ index ]);
}
}, [ data, onScroll ]);
}
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
import { Alert, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract'; import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import useAccount from 'lib/web3/useAccount';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount';
interface Props { interface Props {
isLoading?: boolean; isLoading?: boolean;
} }
const ContractRead = ({ isLoading }: Props) => { const ContractRead = ({ isLoading }: Props) => {
const apiFetch = useApiFetch(); const { address } = useAccount();
const account = useWatchAccount();
const router = useRouter(); const router = useRouter();
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
...@@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => { ...@@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
queryParams: { queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false', is_custom_abi: isCustomAbi ? 'true' : 'false',
from: account?.address, from: address,
}, },
queryOptions: { queryOptions: {
enabled: !isLoading, enabled: !isLoading,
}, },
}); });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<unknown>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: item.method_id,
contract_type: isProxy ? 'proxy' : 'regular',
from: account?.address,
},
},
});
}, [ account?.address, addressHash, apiFetch, isCustomAbi, isProxy ]);
const renderItemContent = React.useCallback((item: SmartContractReadMethod, index: number, id: number) => {
if (item.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ item.error }</Alert>;
}
if (item.outputs?.some(({ value }) => value !== undefined && value !== null)) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ item.outputs.map((output, index) => <ContractMethodConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractReadResult }
methodType="read"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => { ...@@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => {
return ( return (
<> <>
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
{ account && <ContractConnectWallet/> } { config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/> <ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="read"/>
</> </>
); );
}; };
......
import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractMethodReadResult } from './types';
import type { SmartContractQueryMethodReadSuccess, SmartContractReadMethod } from 'types/api/contract';
import hexToUtf8 from 'lib/hexToUtf8';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
const ContractReadResultError = ({ children }: {children: React.ReactNode}) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</Alert>
);
};
interface ItemProps {
output: SmartContractQueryMethodReadSuccess['result']['output'][0];
name: SmartContractQueryMethodReadSuccess['result']['names'][0];
}
const ContractReadResultItem = ({ output, name }: ItemProps) => {
if (Array.isArray(name)) {
const [ structName, argNames ] = name;
const argTypes = output.type.match(TUPLE_TYPE_REGEX)?.[1].split(',');
return (
<>
<p>
<chakra.span fontWeight={ 500 }> { structName }</chakra.span>
<span> ({ output.type }) :</span>
</p>
{ argNames.map((argName, argIndex) => {
return (
<p key={ argName }>
<chakra.span fontWeight={ 500 }> { argName }</chakra.span>
<span>{ argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) }</span>
</p>
);
}) }
</>
);
}
return (
<p>
<span> </span>
{ name && <chakra.span fontWeight={ 500 }>{ name } </chakra.span> }
<span>({ output.type }) : { String(output.value) }</span>
</p>
);
};
interface Props {
item: SmartContractReadMethod;
result: ContractMethodReadResult;
onSettle: () => void;
}
const ContractReadResult = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractReadResultError>{ result.statusText }</ContractReadResultError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractReadResultError>{ result.result.error }</ContractReadResultError>;
}
if ('message' in result.result) {
return <ContractReadResultError>[{ result.result.code }] { result.result.message }</ContractReadResultError>;
}
if ('raw' in result.result) {
return <ContractReadResultError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractReadResultError>;
}
if ('method_id' in result.result) {
return <ContractReadResultError>{ JSON.stringify(result.result, undefined, 2) }</ContractReadResultError>;
}
return <ContractReadResultError>Something went wrong.</ContractReadResultError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractReadResultItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractReadResult);
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { SmartContractWriteMethod } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils';
interface Props { interface Props {
isLoading?: boolean; isLoading?: boolean;
} }
const ContractWrite = ({ isLoading }: Props) => { const ContractWrite = ({ isLoading }: Props) => {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
const router = useRouter(); const router = useRouter();
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
...@@ -46,63 +35,6 @@ const ContractWrite = ({ isLoading }: Props) => { ...@@ -46,63 +35,6 @@ const ContractWrite = ({ isLoading }: Props) => {
}, },
}); });
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<unknown>) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (!contractAbi) {
throw new Error('Something went wrong. Try again later.');
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient?.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { hash };
}
const methodName = item.name;
if (!methodName) {
throw new Error('Method name is not defined');
}
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const abi = prepareAbi(contractAbi, item);
const hash = await walletClient?.writeContract({
args: _args,
abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { hash };
}, [ isConnected, chainId, contractAbi, walletClient, addressHash, switchChainAsync ]);
const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractWriteResult }
methodType="write"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => { ...@@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => {
return ( return (
<> <>
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/> { config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/> <ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="write"/>
</> </>
); );
}; };
......
import React from 'react';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ResultComponentProps } from './methodForm/types';
import type { ContractMethodWriteResult } from './types';
import type { SmartContractWriteMethod } from 'types/api/contract';
import ContractWriteResultDumb from './ContractWriteResultDumb';
const ContractWriteResult = ({ result, onSettle }: ResultComponentProps<SmartContractWriteMethod>) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractWriteResultDumb result={ result as ContractMethodWriteResult } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export default React.memo(ContractWriteResult) as typeof ContractWriteResult;
import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethod } from 'types/api/contract';
export interface ResultComponentProps<T extends SmartContractMethod> {
item: T;
result: ContractMethodCallResult<T>;
onSettle: () => void;
}
import type { SmartContractMethodArgType } from 'types/api/contract';
import { INT_REGEXP, getIntBoundaries } from './utils';
interface Params {
argType: SmartContractMethodArgType;
}
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: bigint;
max: bigint;
}
export default function useArgTypeMatchInt({ argType }: Params): MatchInt | null {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned: Boolean(isUnsigned), power, min, max };
}
import type { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
export type MethodFormFields = Record<string, string | Array<string>>;
export type MethodFormFieldsFormatted = Record<string, MethodArgType>;
export type MethodArgType = string | boolean | Array<MethodArgType>;
export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError;
export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined;
export type ContractMethodCallResult<T extends SmartContractMethod> =
T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult;
import { useQueryClient } from '@tanstack/react-query';
import type { Abi } from 'abitype';
import React from 'react';
import type { Address } from 'types/api/address';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
interface Params {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
export default function useContractAbi({ addressHash, isProxy, isCustomAbi }: Params): Abi | undefined {
const queryClient = useQueryClient();
const { data: contractInfo } = useApiQuery('contract', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash),
refetchOnMount: false,
},
});
const addressInfo = queryClient.getQueryData<Address>(getResourceKey('address', {
pathParams: { hash: addressHash },
}));
const { data: proxyInfo } = useApiQuery('contract', {
pathParams: { hash: addressInfo?.implementation_address || '' },
queryOptions: {
enabled: Boolean(addressInfo?.implementation_address),
refetchOnMount: false,
},
});
const { data: customInfo } = useApiQuery('contract_methods_write', {
pathParams: { hash: addressHash },
queryParams: { is_custom_abi: 'true' },
queryOptions: {
enabled: Boolean(contractInfo?.has_custom_methods_write),
refetchOnMount: false,
},
});
return React.useMemo(() => {
if (isProxy) {
return proxyInfo?.abi ?? undefined;
}
if (isCustomAbi) {
return customInfo as Abi;
}
return contractInfo?.abi ?? undefined;
}, [ contractInfo?.abi, customInfo, isCustomAbi, isProxy, proxyInfo?.abi ]);
}
import { watchAccount, getAccount } from '@wagmi/core';
import React from 'react';
import type { Config } from 'wagmi';
import { useConfig } from 'wagmi';
export function getWalletAccount(config: Config) {
try {
return getAccount(config);
} catch (error) {
return null;
}
}
export default function useWatchAccount() {
const config = useConfig();
const [ account, setAccount ] = React.useState(getWalletAccount(config));
React.useEffect(() => {
if (!account) {
return;
}
return watchAccount(config, {
onChange(account) {
setAccount(account);
},
});
}, [ account, config ]);
return account;
}
import { prepareAbi } from './utils';
describe('function prepareAbi()', () => {
const commonAbi = [
{
inputs: [
{ internalType: 'address', name: '_pool', type: 'address' },
{ internalType: 'address', name: '_token', type: 'address' },
{ internalType: 'uint256', name: '_denominator', type: 'uint256' },
],
stateMutability: 'nonpayable' as const,
type: 'constructor' as const,
},
{
anonymous: false,
inputs: [
{ indexed: false, internalType: 'uint256[]', name: 'indices', type: 'uint256[]' },
],
name: 'CompleteDirectDepositBatch',
type: 'event' as const,
},
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
{ internalType: 'string', name: '_zkAddress', type: 'string' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable' as const,
type: 'function' as const,
},
];
const method = {
inputs: [
{ internalType: 'address' as const, name: '_fallbackUser', type: 'address' as const },
{ internalType: 'string' as const, name: '_zkAddress', type: 'string' as const },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256' as const, name: '', type: 'uint256' as const },
],
stateMutability: 'payable' as const,
type: 'function' as const,
constant: false,
payable: true,
method_id: '0x2e0e2d3e',
};
it('if there is only one method with provided name, does nothing', () => {
const abi = prepareAbi(commonAbi, method);
expect(abi).toHaveLength(commonAbi.length);
});
it('if there are two or more methods with the same name and inputs length, filters out those which input types are not matched', () => {
const abi = prepareAbi([
...commonAbi,
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
{ internalType: 'bytes', name: '_rawZkAddress', type: 'bytes' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable',
type: 'function',
},
], method);
expect(abi).toHaveLength(commonAbi.length);
const item = abi.find((item) => 'name' in item ? item.name === method.name : false);
expect(item).toEqual(commonAbi[2]);
});
it('if there are two or more methods with the same name and different inputs length, filters out those which inputs are not matched', () => {
const abi = prepareAbi([
...commonAbi,
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable',
type: 'function',
},
], method);
expect(abi).toHaveLength(commonAbi.length);
const item = abi.find((item) => 'name' in item ? item.name === method.name : false);
expect(item).toEqual(commonAbi[2]);
});
});
import type { Abi } from 'abitype';
import type { SmartContractWriteMethod } from 'types/api/contract';
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
if ('name' in item) {
const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1;
if (hasMethodsWithSameName) {
return abi.filter((abiItem) => {
if (!('name' in abiItem)) {
return true;
}
if (abiItem.name !== item.name) {
return true;
}
if (abiItem.inputs.length !== item.inputs.length) {
return false;
}
return abiItem.inputs.every(({ name, type }) => {
const itemInput = item.inputs.find((input) => input.name === name);
return Boolean(itemInput) && itemInput?.type === type;
});
});
}
}
return abi;
}
import React from 'react'; import React from 'react';
import * as blockMock from 'mocks/blocks/block'; import * as blockMock from 'mocks/blocks/block';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs';
import BlockDetails from './BlockDetails'; import BlockDetails from './BlockDetails';
import type { BlockQuery } from './useBlockQuery'; import type { BlockQuery } from './useBlockQuery';
...@@ -15,43 +13,33 @@ const hooksConfig = { ...@@ -15,43 +13,33 @@ const hooksConfig = {
}, },
}; };
test('regular block +@mobile +@dark-mode', async({ mount, page }) => { test('regular block +@mobile +@dark-mode', async({ render, page }) => {
const query = { const query = {
data: blockMock.base, data: blockMock.base,
isPending: false, isPending: false,
} as BlockQuery; } as BlockQuery;
const component = await mount( const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click(); await page.getByText('View details').click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('genesis block', async({ mount, page }) => { test('genesis block', async({ render, page }) => {
const query = { const query = {
data: blockMock.genesis, data: blockMock.genesis,
isPending: false, isPending: false,
} as BlockQuery; } as BlockQuery;
const component = await mount( const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click(); await page.getByText('View details').click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with blob txs', async({ mount, page, mockEnvs }) => { test('with blob txs', async({ render, page, mockEnvs }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED', 'true' ], [ 'NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED', 'true' ],
]); ]);
...@@ -60,35 +48,21 @@ test('with blob txs', async({ mount, page, mockEnvs }) => { ...@@ -60,35 +48,21 @@ test('with blob txs', async({ mount, page, mockEnvs }) => {
isPending: false, isPending: false,
} as BlockQuery; } as BlockQuery;
const component = await mount( const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click(); await page.getByText('View details').click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
const customFieldsTest = test.extend({ test('rootstock custom fields', async({ render, page, mockEnvs }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.blockHiddenFields);
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any,
});
customFieldsTest('rootstock custom fields', async({ mount, page }) => {
const query = { const query = {
data: blockMock.rootstock, data: blockMock.rootstock,
isPending: false, isPending: false,
} as BlockQuery; } as BlockQuery;
const component = await mount( const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click(); await page.getByText('View details').click();
......
...@@ -220,28 +220,28 @@ const BlockDetails = ({ query }: Props) => { ...@@ -220,28 +220,28 @@ const BlockDetails = ({ query }: Props) => {
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && ( { rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && !config.UI.views.block.hiddenFields?.batch && (
<> <DetailsInfoItem
<DetailsInfoItem title="Batch"
title="Batch" hint="Batch number"
hint="Batch number" isLoading={ isPlaceholderData }
isLoading={ isPlaceholderData } >
> { data.zksync.batch_number ? (
{ data.zksync.batch_number ? ( <BatchEntityL2
<BatchEntityL2 isLoading={ isPlaceholderData }
isLoading={ isPlaceholderData } number={ data.zksync.batch_number }
number={ data.zksync.batch_number } />
/> ) : <Skeleton isLoaded={ !isPlaceholderData }>Pending</Skeleton> }
) : <Skeleton isLoaded={ !isPlaceholderData }>Pending</Skeleton> } </DetailsInfoItem>
</DetailsInfoItem> ) }
<DetailsInfoItem { rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && !config.UI.views.block.hiddenFields?.L1_status && (
title="Status" <DetailsInfoItem
hint="Status is the short interpretation of the batch lifecycle" title="Status"
isLoading={ isPlaceholderData } hint="Status is the short interpretation of the batch lifecycle"
> isLoading={ isPlaceholderData }
<VerificationSteps steps={ ZKSYNC_L2_TX_BATCH_STATUSES } currentStep={ data.zksync.status } isLoading={ isPlaceholderData }/> >
</DetailsInfoItem> <VerificationSteps steps={ ZKSYNC_L2_TX_BATCH_STATUSES } currentStep={ data.zksync.status } isLoading={ isPlaceholderData }/>
</> </DetailsInfoItem>
) } ) }
{ !config.UI.views.block.hiddenFields?.miner && ( { !config.UI.views.block.hiddenFields?.miner && (
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as statsMock from 'mocks/stats/index'; import * as statsMock from 'mocks/stats/index';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { test, expect } from 'playwright/lib';
import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs'; import * as configs from 'playwright/utils/configs';
import GasTrackerPriceSnippet from './GasTrackerPriceSnippet'; import GasTrackerPriceSnippet from './GasTrackerPriceSnippet';
...@@ -13,48 +11,57 @@ test.use({ viewport: configs.viewport.md }); ...@@ -13,48 +11,57 @@ test.use({ viewport: configs.viewport.md });
const data = statsMock.base.gas_prices.fast; const data = statsMock.base.gas_prices.fast;
const clip = { x: 0, y: 0, width: 334, height: 204 }; const clip = { x: 0, y: 0, width: 334, height: 204 };
test('with usd as primary unit +@dark-mode', async({ mount, page }) => { test('with usd as primary unit +@dark-mode', async({ render, page }) => {
await mount( await render(
<TestApp> <GasTrackerPriceSnippet
<GasTrackerPriceSnippet data={ data }
data={ data } type="fast"
type="fast" isLoading={ false }
isLoading={ false } />,
/>
</TestApp>,
); );
await expect(page).toHaveScreenshot({ clip }); await expect(page).toHaveScreenshot({ clip });
}); });
test('loading state', async({ mount, page }) => { test('loading state', async({ render, page }) => {
await mount( await render(
<TestApp> <GasTrackerPriceSnippet
<GasTrackerPriceSnippet data={ data }
data={ data } type="fast"
type="fast" isLoading={ true }
isLoading={ true } />,
/>
</TestApp>,
); );
await expect(page).toHaveScreenshot({ clip }); await expect(page).toHaveScreenshot({ clip });
}); });
const gweiUnitsTest = test.extend({ test('with gwei as primary unit +@dark-mode', async({ render, page, mockEnvs }) => {
context: contextWithEnvs([ await mockEnvs([
{ name: 'NEXT_PUBLIC_GAS_TRACKER_UNITS', value: '["gwei","usd"]' }, [ 'NEXT_PUBLIC_GAS_TRACKER_UNITS', '["gwei","usd"]' ],
// eslint-disable-next-line @typescript-eslint/no-explicit-any ]);
]) as any, await render(
<GasTrackerPriceSnippet
data={ data }
type="slow"
isLoading={ false }
/>,
);
await expect(page).toHaveScreenshot({ clip });
}); });
gweiUnitsTest('with gwei as primary unit +@dark-mode', async({ mount, page }) => { test('with zero values', async({ render, page }) => {
await mount( const data = {
<TestApp> fiat_price: '1.74',
<GasTrackerPriceSnippet price: 0.0,
data={ data } time: 0,
type="slow" base_fee: 0,
isLoading={ false } priority_fee: 0,
/> };
</TestApp>,
await render(
<GasTrackerPriceSnippet
data={ data }
type="slow"
isLoading={ false }
/>,
); );
await expect(page).toHaveScreenshot({ clip }); await expect(page).toHaveScreenshot({ clip });
}); });
...@@ -50,14 +50,14 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => { ...@@ -50,14 +50,14 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => {
</Skeleton> </Skeleton>
</Flex> </Flex>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 3 } w="fit-content"> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 3 } w="fit-content">
{ data.price && data.fiat_price && <GasPrice data={ data } prefix={ `${ asymp } ` } unitMode="secondary"/> } { data.price !== null && data.fiat_price !== null && <GasPrice data={ data } prefix={ `${ asymp } ` } unitMode="secondary"/> }
<span> per transaction</span> <span> per transaction</span>
{ typeof data.time === 'number' && data.time > 0 && <span> / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s</span> } { typeof data.time === 'number' && data.time > 0 && <span> / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s</span> }
</Skeleton> </Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 2 } w="fit-content" whiteSpace="pre"> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 2 } w="fit-content" whiteSpace="pre">
{ data.base_fee && <span>Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> } { typeof data.base_fee === 'number' && <span>Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
{ data.base_fee && data.priority_fee && <span> / </span> } { typeof data.base_fee === 'number' && typeof data.priority_fee === 'number' && <span> / </span> }
{ data.priority_fee && <span>Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> } { typeof data.priority_fee === 'number' && <span>Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
</Skeleton> </Skeleton>
</Box> </Box>
); );
......
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 statsMock from 'mocks/stats/index'; import * as statsMock from 'mocks/stats/index';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer'; import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import LatestBlocks from './LatestBlocks'; import LatestBlocks from './LatestBlocks';
const STATS_API_URL = buildApiUrl('stats'); test('default view +@mobile +@dark-mode', async({ render, mockApiResponse }) => {
const BLOCKS_API_URL = buildApiUrl('homepage_blocks'); await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
export const test = base.extend<socketServer.SocketServerFixture>({ const component = await render(<LatestBlocks/>);
createSocket: socketServer.createSocket,
});
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
const testL2 = test.extend({ test('L2 view', async({ render, mockEnvs, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.optimisticRollup);
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, await mockApiResponse('stats', statsMock.base);
}); await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
const component = await render(<LatestBlocks/>);
testL2('L2 view', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
const testNoReward = test.extend({ test('no reward view', async({ render, mockEnvs, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.blockHiddenFields);
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any, await mockApiResponse('stats', statsMock.base);
}); await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
const component = await render(<LatestBlocks/>);
testNoReward('no reward view', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with long block height', async({ mount, page }) => { test('with long block height', async({ render, mockApiResponse }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({ await mockApiResponse('stats', statsMock.base);
status: 200, await mockApiResponse('homepage_blocks', [ { ...blockMock.base, height: 123456789012345 } ]);
body: JSON.stringify(statsMock.base), const component = await render(<LatestBlocks/>);
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
{
...blockMock.base,
height: 123456789012345,
},
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test.describe('socket', () => { test.describe('socket', () => {
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
test('new item', async({ render, mockApiResponse, createSocket }) => {
test('new item', async({ mount, page, createSocket }) => { await mockApiResponse('stats', statsMock.base);
await page.route(STATS_API_URL, (route) => route.fulfill({ await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
status: 200, const component = await render(<LatestBlocks/>, undefined, { withSocket: true });
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp withSocket>
<LatestBlocks/>
</TestApp>,
);
const socket = await createSocket(); const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'blocks:new_block'); const channel = await socketServer.joinChannel(socket, 'blocks:new_block');
socketServer.sendMessage(socket, channel, 'new_block', { socketServer.sendMessage(socket, channel, 'new_block', {
...@@ -150,7 +54,6 @@ test.describe('socket', () => { ...@@ -150,7 +54,6 @@ test.describe('socket', () => {
timestamp: '2022-11-11T11:59:58Z', timestamp: '2022-11-11T11:59:58Z',
}, },
}); });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
}); });
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as depositMock from 'mocks/l2deposits/deposits'; import * as depositMock from 'mocks/l2deposits/deposits';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import LatestDeposits from './LatestDeposits'; import LatestDeposits from './LatestDeposits';
const test = base.extend({ test('default view +@mobile +@dark-mode', async({ render, mockApiResponse, mockEnvs }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.optimisticRollup);
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, mockApiResponse('homepage_deposits', depositMock.data.items);
}); const component = await render(<LatestDeposits/>);
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(buildApiUrl('homepage_deposits'), (route) => route.fulfill({
status: 200,
body: JSON.stringify(depositMock.data.items),
}));
const component = await mount(
<TestApp>
<LatestDeposits/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { txnBatchesData } from 'mocks/zkEvm/txnBatches'; import { txnBatchesData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import LatestZkEvmL2Batches from './LatestZkEvmL2Batches'; import LatestZkEvmL2Batches from './LatestZkEvmL2Batches';
const BATCHES_API_URL = buildApiUrl('homepage_zkevm_l2_batches'); test('default view +@mobile +@dark-mode', async({ render, mockEnvs, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.zkEvmRollup);
const test = base.extend({ await mockApiResponse('homepage_zkevm_l2_batches', txnBatchesData);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any,
});
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchesData),
}));
const component = await mount(
<TestApp>
<LatestZkEvmL2Batches/>
</TestApp>,
);
const component = await render(<LatestZkEvmL2Batches/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -20,7 +20,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( ...@@ -20,7 +20,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
text={ text={
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? ( (selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<> <>
You don{ apos }t have any favorite apps. You don{ apos }t have any favorite apps.<br/>
Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites. Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites.
</> </>
) : ( ) : (
...@@ -28,7 +28,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( ...@@ -28,7 +28,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
No matching apps found. No matching apps found.
{ 'suggestIdeasFormUrl' in feature && ( { 'suggestIdeasFormUrl' in feature && (
<> <>
{ ' ' }Have a groundbreaking idea or app suggestion?{ ' ' } { ' ' }Have a groundbreaking idea or app suggestion?<br/>
<LinkExternal href={ feature.suggestIdeasFormUrl }>Share it with us</LinkExternal> <LinkExternal href={ feature.suggestIdeasFormUrl }>Share it with us</LinkExternal>
</> </>
) } ) }
......
...@@ -7,7 +7,6 @@ import React, { useCallback } from 'react'; ...@@ -7,7 +7,6 @@ import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'; import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace'; import { ContractListTypes } from 'types/client/marketplace';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
...@@ -33,7 +32,6 @@ const MarketplaceAppModal = ({ ...@@ -33,7 +32,6 @@ const MarketplaceAppModal = ({
data, data,
showContractList: showContractListProp, showContractList: showContractListProp,
}: Props) => { }: Props) => {
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300'); const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
const { const {
...@@ -47,6 +45,7 @@ const MarketplaceAppModal = ({ ...@@ -47,6 +45,7 @@ const MarketplaceAppModal = ({
github, github,
telegram, telegram,
twitter, twitter,
discord,
logo, logo,
logoDarkMode, logoDarkMode,
categories, categories,
...@@ -62,6 +61,10 @@ const MarketplaceAppModal = ({ ...@@ -62,6 +61,10 @@ const MarketplaceAppModal = ({
icon: 'social/twitter_filled' as IconName, icon: 'social/twitter_filled' as IconName,
url: twitter, url: twitter,
} : null, } : null,
discord ? {
icon: 'social/discord_filled' as IconName,
url: discord,
} : null,
].filter(Boolean); ].filter(Boolean);
if (github) { if (github) {
...@@ -183,7 +186,7 @@ const MarketplaceAppModal = ({ ...@@ -183,7 +186,7 @@ const MarketplaceAppModal = ({
/> />
</Flex> </Flex>
{ (isExperiment && securityReport) && ( { securityReport && (
<Flex alignItems="center" gap={ 3 }> <Flex alignItems="center" gap={ 3 }>
<AppSecurityReport <AppSecurityReport
id={ id } id={ id }
......
...@@ -8,7 +8,6 @@ import { route } from 'nextjs-routes'; ...@@ -8,7 +8,6 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal'; import LinkExternal from 'ui/shared/LinkExternal';
...@@ -31,7 +30,6 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { ...@@ -31,7 +30,6 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
const [ showContractList, setShowContractList ] = useBoolean(false); const [ showContractList, setShowContractList ] = useBoolean(false);
const appProps = useAppContext(); const appProps = useAppContext();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const goBackUrl = React.useMemo(() => { const goBackUrl = React.useMemo(() => {
if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) { if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) {
...@@ -72,7 +70,7 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { ...@@ -72,7 +70,7 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
<Skeleton isLoaded={ !isLoading }> <Skeleton isLoaded={ !isLoading }>
<MarketplaceAppInfo data={ data }/> <MarketplaceAppInfo data={ data }/>
</Skeleton> </Skeleton>
{ (isExperiment && (securityReport || isLoading)) && ( { (securityReport || isLoading) && (
<AppSecurityReport <AppSecurityReport
id={ data?.id || '' } id={ data?.id || '' }
securityReport={ securityReport } securityReport={ securityReport }
......
...@@ -32,7 +32,7 @@ const MarketplaceListWithScores = ({ ...@@ -32,7 +32,7 @@ const MarketplaceListWithScores = ({
showContractList, showContractList,
}: Props) => { }: Props) => {
const displayedApps = React.useMemo(() => apps.sort((a, b) => { const displayedApps = React.useMemo(() => [ ...apps ].sort((a, b) => {
if (!a.securityReport) { if (!a.securityReport) {
return 1; return 1;
} else if (!b.securityReport) { } else if (!b.securityReport) {
......
...@@ -18,13 +18,13 @@ function isAppNameMatches(q: string, app: MarketplaceAppWithSecurityReport) { ...@@ -18,13 +18,13 @@ function isAppNameMatches(q: string, app: MarketplaceAppWithSecurityReport) {
return app.title.toLowerCase().includes(q.toLowerCase()); return app.title.toLowerCase().includes(q.toLowerCase());
} }
function isAppCategoryMatches(category: string, app: MarketplaceAppWithSecurityReport, favoriteApps: Array<string>) { function isAppCategoryMatches(category: string, app: MarketplaceAppWithSecurityReport, favoriteApps: Array<string> = []) {
return category === MarketplaceCategory.ALL || return category === MarketplaceCategory.ALL ||
(category === MarketplaceCategory.FAVORITES && favoriteApps.includes(app.id)) || (category === MarketplaceCategory.FAVORITES && favoriteApps.includes(app.id)) ||
app.categories.includes(category); app.categories.includes(category);
} }
function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: Array<string>) { function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: Array<string> = []) {
return apps.sort((a, b) => { return apps.sort((a, b) => {
const priorityA = a.priority || 0; const priorityA = a.priority || 0;
const priorityB = b.priority || 0; const priorityB = b.priority || 0;
...@@ -60,17 +60,19 @@ export default function useMarketplaceApps( ...@@ -60,17 +60,19 @@ export default function useMarketplaceApps(
const { data: securityReports, isPlaceholderData: isSecurityReportsPlaceholderData } = useSecurityReports(); const { data: securityReports, isPlaceholderData: isSecurityReportsPlaceholderData } = useSecurityReports();
// Update favorite apps only when selectedCategoryId changes to avoid sortApps to be called on each favorite app click // Set the value only 1 time to avoid unnecessary useQuery calls and re-rendering of all applications
const [ snapshotFavoriteApps, setSnapshotFavoriteApps ] = React.useState<Array<string> | undefined>(); const [ snapshotFavoriteApps, setSnapshotFavoriteApps ] = React.useState<Array<string> | undefined>();
const isInitialSetup = React.useRef(true);
React.useEffect(() => { React.useEffect(() => {
if (isFavoriteAppsLoaded) { if (isInitialSetup.current && (isFavoriteAppsLoaded || favoriteApps === undefined)) {
setSnapshotFavoriteApps(favoriteApps); setSnapshotFavoriteApps(favoriteApps || []);
isInitialSetup.current = false;
} }
}, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps }, [ isFavoriteAppsLoaded, favoriteApps ]);
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppWithSecurityReport>>({ const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppWithSecurityReport>>({
queryKey: [ 'marketplace-dapps', snapshotFavoriteApps, favoriteApps ], queryKey: [ 'marketplace-dapps', snapshotFavoriteApps ],
queryFn: async() => { queryFn: async() => {
if (!feature.isEnabled) { if (!feature.isEnabled) {
return []; return [];
...@@ -80,10 +82,10 @@ export default function useMarketplaceApps( ...@@ -80,10 +82,10 @@ export default function useMarketplaceApps(
return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } }); return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } });
} }
}, },
select: (data) => sortApps(data as Array<MarketplaceAppWithSecurityReport>, snapshotFavoriteApps || []), select: (data) => sortApps(data as Array<MarketplaceAppWithSecurityReport>, snapshotFavoriteApps),
placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined, placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity, staleTime: Infinity,
enabled: feature.isEnabled && (!favoriteApps || Boolean(snapshotFavoriteApps)), enabled: feature.isEnabled && Boolean(snapshotFavoriteApps),
}); });
const appsWithSecurityReports = React.useMemo(() => const appsWithSecurityReports = React.useMemo(() =>
...@@ -91,7 +93,7 @@ export default function useMarketplaceApps( ...@@ -91,7 +93,7 @@ export default function useMarketplaceApps(
[ data, securityReports ]); [ data, securityReports ]);
const displayedApps = React.useMemo(() => { const displayedApps = React.useMemo(() => {
return appsWithSecurityReports?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps || [])) || []; return appsWithSecurityReports?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps ]); }, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps ]);
return React.useMemo(() => ({ return React.useMemo(() => ({
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as addressCountersMock from 'mocks/address/counters';
import * as addressTabCountersMock from 'mocks/address/tabCounters';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import Address from './Address';
const hooksConfig = {
router: {
query: { hash: addressMock.hash },
},
};
test.describe('fetched bytecode', () => {
test('should refetch address query', async({ render, mockApiResponse, createSocket, page }) => {
const addressApiUrl = await mockApiResponse('address', addressMock.validator, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('address_counters', addressCountersMock.forValidator, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('address_tabs_counters', addressTabCountersMock.base, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('address_txs', { items: [], next_page_params: null }, { pathParams: { hash: addressMock.hash } });
await render(<Address/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, `addresses:${ addressMock.hash.toLowerCase() }`);
socketServer.sendMessage(socket, channel, 'fetched_bytecode', { fetched_bytecode: '0x0123' });
const request = await page.waitForRequest(addressApiUrl);
expect(request).toBeTruthy();
});
});
...@@ -2,14 +2,18 @@ import { Box, Flex, HStack, useColorModeValue } from '@chakra-ui/react'; ...@@ -2,14 +2,18 @@ import { Box, Flex, HStack, useColorModeValue } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { EntityTag } from 'ui/shared/EntityTags/types';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import useContractTabs from 'lib/hooks/useContractTabs'; import useContractTabs from 'lib/hooks/useContractTabs';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { ADDRESS_TABS_COUNTERS } from 'stubs/address'; import { ADDRESS_TABS_COUNTERS } from 'stubs/address';
import { USER_OPS_ACCOUNT } from 'stubs/userOps'; import { USER_OPS_ACCOUNT } from 'stubs/userOps';
import AddressAccountHistory from 'ui/address/AddressAccountHistory'; import AddressAccountHistory from 'ui/address/AddressAccountHistory';
...@@ -34,7 +38,9 @@ import TextAd from 'ui/shared/ad/TextAd'; ...@@ -34,7 +38,9 @@ import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity'; import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import sortEntityTags from 'ui/shared/EntityTags/sortEntityTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -69,14 +75,31 @@ const AddressPageContent = () => { ...@@ -69,14 +75,31 @@ const AddressPageContent = () => {
}, },
}); });
const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
const handleFetchedBytecodeMessage = React.useCallback(() => {
addressQuery.refetch();
}, [ addressQuery ]);
const channel = useSocketChannel({
topic: `addresses:${ hash?.toLowerCase() }`,
isDisabled: isTabsLoading || addressQuery.isDegradedData || Boolean(addressQuery.data?.is_contract),
});
useSocketMessage({
channel,
event: 'fetched_bytecode',
handler: handleFetchedBytecodeMessage,
});
const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined); const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined);
const safeIconColor = useColorModeValue('black', 'white'); const safeIconColor = useColorModeValue('black', 'white');
const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData); const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
const tabs: Array<RoutedTab> = React.useMemo(() => { const tabs: Array<RoutedTab> = React.useMemo(() => {
return [ return [
{ {
...@@ -169,18 +192,27 @@ const AddressPageContent = () => { ...@@ -169,18 +192,27 @@ const AddressPageContent = () => {
].filter(Boolean); ].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]); }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]);
const tags = ( const tags: Array<EntityTag> = React.useMemo(() => {
return [
!addressQuery.data?.is_contract ? { slug: 'eoa', name: 'EOA', tagType: 'custom' as const, ordinal: -1 } : undefined,
config.features.validators.isEnabled && addressQuery.data?.has_validated_blocks ?
{ slug: 'validator', name: 'Validator', tagType: 'custom' as const, ordinal: 10 } :
undefined,
addressQuery.data?.implementation_address ? { slug: 'proxy', name: 'Proxy', tagType: 'custom' as const, ordinal: -1 } : undefined,
addressQuery.data?.token ? { slug: 'token', name: 'Token', tagType: 'custom' as const, ordinal: -1 } : undefined,
isSafeAddress ? { slug: 'safe', name: 'Multisig: Safe', tagType: 'custom' as const, ordinal: -10 } : undefined,
config.features.userOps.isEnabled && userOpsAccountQuery.data ?
{ slug: 'user_ops_acc', name: 'Smart contract wallet', tagType: 'custom' as const, ordinal: -10 } :
undefined,
...formatUserTags(addressQuery.data),
...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []),
].filter(Boolean).sort(sortEntityTags);
}, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data ]);
const titleContentAfter = (
<EntityTags <EntityTags
data={ addressQuery.data } tags={ tags }
isLoading={ isLoading } isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
tagsBefore={ [
!addressQuery.data?.is_contract ? { label: 'eoa', display_name: 'EOA' } : undefined,
config.features.validators.isEnabled && addressQuery.data?.has_validated_blocks ? { label: 'validator', display_name: 'Validator' } : undefined,
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined,
config.features.userOps.isEnabled && userOpsAccountQuery.data ? { label: 'user_ops_acc', display_name: 'Smart contract wallet' } : undefined,
] }
/> />
); );
...@@ -245,7 +277,7 @@ const AddressPageContent = () => { ...@@ -245,7 +277,7 @@ const AddressPageContent = () => {
<PageTitle <PageTitle
title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` } title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
backLink={ backLink } backLink={ backLink }
contentAfter={ tags } contentAfter={ titleContentAfter }
secondRow={ titleSecondRow } secondRow={ titleSecondRow }
isLoading={ isLoading } isLoading={ isLoading }
/> />
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { data as withdrawalsData } from 'mocks/withdrawals/withdrawals'; import { data as withdrawalsData } from 'mocks/withdrawals/withdrawals';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import BeaconChainWithdrawals from './BeaconChainWithdrawals'; import BeaconChainWithdrawals from './BeaconChainWithdrawals';
const test = base.extend({ test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.beaconChain);
context: contextWithEnvs(configs.featureEnvs.beaconChain) as any, await mockTextAd();
}); await mockApiResponse('withdrawals', withdrawalsData);
await mockApiResponse('withdrawals_counters', { withdrawal_count: '111111', withdrawal_sum: '1010101010110101001101010' });
const WITHDRAWALS_API_URL = buildApiUrl('withdrawals'); const component = await render(<BeaconChainWithdrawals/>);
const WITHDRAWALS_COUNTERS_API_URL = buildApiUrl('withdrawals_counters');
test('base view +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(WITHDRAWALS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withdrawalsData),
}));
await page.route(WITHDRAWALS_COUNTERS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ withdrawal_count: '111111', withdrawal_sum: '1010101010110101001101010' }),
}));
const component = await mount(
<TestApp>
<BeaconChainWithdrawals/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import type { BrowserContext } from '@playwright/test';
import React from 'react'; import React from 'react';
import * as blockMock from 'mocks/blocks/block'; import * as blockMock from 'mocks/blocks/block';
import * as statsMock from 'mocks/stats/index'; import * as statsMock from 'mocks/stats/index';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer'; import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import * as configs from 'playwright/utils/configs';
import Blocks from './Blocks'; import Blocks from './Blocks';
...@@ -57,11 +54,8 @@ test.describe('mobile', () => { ...@@ -57,11 +54,8 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
const hiddenFieldsTest = test.extend<{ context: BrowserContext }>({ test('hidden fields', async({ render, mockApiResponse, mockEnvs }) => {
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields), await mockEnvs(ENVS_MAP.blockHiddenFields);
});
hiddenFieldsTest('hidden fields', async({ render, mockApiResponse }) => {
await mockApiResponse('blocks', blockMock.baseListResponse, { queryParams: { type: 'block' } }); await mockApiResponse('blocks', blockMock.baseListResponse, { queryParams: { type: 'block' } });
await mockApiResponse('stats', statsMock.base); await mockApiResponse('stats', statsMock.base);
......
...@@ -36,7 +36,8 @@ const GasTracker = () => { ...@@ -36,7 +36,8 @@ const GasTracker = () => {
rowGap={ 1 } rowGap={ 1 }
flexDir={{ base: 'column', lg: 'row' }} flexDir={{ base: 'column', lg: 'row' }}
> >
{ data?.network_utilization_percentage && <GasTrackerNetworkUtilization percentage={ data.network_utilization_percentage } isLoading={ isLoading }/> } { typeof data?.network_utilization_percentage === 'number' &&
<GasTrackerNetworkUtilization percentage={ data.network_utilization_percentage } isLoading={ isLoading }/> }
{ data?.gas_price_updated_at && ( { data?.gas_price_updated_at && (
<Skeleton isLoaded={ !isLoading } whiteSpace="pre" display="flex" alignItems="center"> <Skeleton isLoaded={ !isLoading } whiteSpace="pre" display="flex" alignItems="center">
<span>Last updated </span> <span>Last updated </span>
......
...@@ -46,14 +46,11 @@ test('with banner +@dark-mode', async({ render, mockEnvs, mockConfigResponse }) ...@@ -46,14 +46,11 @@ test('with banner +@dark-mode', async({ render, mockEnvs, mockConfigResponse })
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with scores +@dark-mode', async({ render, mockConfigResponse, mockEnvs, mockFeatures }) => { test('with scores +@dark-mode', async({ render, mockConfigResponse, mockEnvs }) => {
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json'; const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
]); ]);
await mockFeatures([
[ 'security_score_exp', true ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
const component = await render(<Marketplace/>); const component = await render(<Marketplace/>);
await component.getByText('Apps scores').click(); await component.getByText('Apps scores').click();
...@@ -95,14 +92,11 @@ test.describe('mobile', () => { ...@@ -95,14 +92,11 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with scores', async({ render, mockConfigResponse, mockEnvs, mockFeatures }) => { test('with scores', async({ render, mockConfigResponse, mockEnvs }) => {
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json'; const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
]); ]);
await mockFeatures([
[ 'security_score_exp', true ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
const component = await render(<Marketplace/>); const component = await render(<Marketplace/>);
await component.getByText('Apps scores').click(); await component.getByText('Apps scores').click();
......
...@@ -7,7 +7,6 @@ import type { TabItem } from 'ui/shared/Tabs/types'; ...@@ -7,7 +7,6 @@ import type { TabItem } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import Banner from 'ui/marketplace/Banner'; import Banner from 'ui/marketplace/Banner';
import ContractListModal from 'ui/marketplace/ContractListModal'; import ContractListModal from 'ui/marketplace/ContractListModal';
...@@ -74,7 +73,6 @@ const Marketplace = () => { ...@@ -74,7 +73,6 @@ const Marketplace = () => {
} = useMarketplace(); } = useMarketplace();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const categoryTabs = React.useMemo(() => { const categoryTabs = React.useMemo(() => {
const tabs: Array<TabItem> = categories.map(category => ({ const tabs: Array<TabItem> = categories.map(category => ({
...@@ -189,7 +187,7 @@ const Marketplace = () => { ...@@ -189,7 +187,7 @@ const Marketplace = () => {
</Box> </Box>
<Flex direction={{ base: 'column', lg: 'row' }} mb={{ base: 4, lg: 6 }} gap={{ base: 4, lg: 3 }}> <Flex direction={{ base: 'column', lg: 'row' }} mb={{ base: 4, lg: 6 }} gap={{ base: 4, lg: 3 }}>
{ (feature.securityReportsUrl && isExperiment) && ( { feature.securityReportsUrl && (
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
<RadioButtonGroup<MarketplaceDisplayType> <RadioButtonGroup<MarketplaceDisplayType>
onChange={ onDisplayTypeChange } onChange={ onDisplayTypeChange }
...@@ -226,12 +224,12 @@ const Marketplace = () => { ...@@ -226,12 +224,12 @@ const Marketplace = () => {
onChange={ onSearchInputChange } onChange={ onSearchInputChange }
placeholder="Find app by name or keyword..." placeholder="Find app by name or keyword..."
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
size={ (feature.securityReportsUrl && isExperiment) ? 'xs' : 'sm' } size={ feature.securityReportsUrl ? 'xs' : 'sm' }
flex="1" flex="1"
/> />
</Flex> </Flex>
{ (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl && isExperiment) ? ( { (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl) ? (
<MarketplaceListWithScores <MarketplaceListWithScores
apps={ displayedApps } apps={ displayedApps }
showAppInfo={ showAppInfo } showAppInfo={ showAppInfo }
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { data as depositsData } from 'mocks/l2deposits/deposits'; import { data as depositsData } from 'mocks/l2deposits/deposits';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import OptimisticL2Deposits from './OptimisticL2Deposits'; import OptimisticL2Deposits from './OptimisticL2Deposits';
const DEPOSITS_API_URL = buildApiUrl('optimistic_l2_deposits'); test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
const DEPOSITS_COUNT_API_URL = buildApiUrl('optimistic_l2_deposits_count');
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
test('base view +@mobile', async({ mount, page }) => {
// test on mobile is flaky // test on mobile is flaky
// my assumption is there is not enough time to calculate hashes truncation so component is unstable // my assumption is there is not enough time to calculate hashes truncation so component is unstable
// so I raised the test timeout to check if it helps // so I raised the test timeout to check if it helps
test.slow(); test.slow();
await mockEnvs(ENVS_MAP.optimisticRollup);
await mockTextAd();
await mockApiResponse('optimistic_l2_deposits', depositsData);
await mockApiResponse('optimistic_l2_deposits_count', 3971111);
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ const component = await render(<OptimisticL2Deposits/>);
status: 200,
body: '',
}));
await page.route(DEPOSITS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(depositsData),
}));
await page.route(DEPOSITS_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '3971111',
}));
const component = await mount(
<TestApp>
<OptimisticL2Deposits/>
</TestApp>,
);
await expect(component).toHaveScreenshot({ timeout: 10_000 }); await expect(component).toHaveScreenshot({ timeout: 10_000 });
}); });
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { outputRootsData } from 'mocks/l2outputRoots/outputRoots'; import { outputRootsData } from 'mocks/l2outputRoots/outputRoots';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import OptimisticL2OutputRoots from './OptimisticL2OutputRoots'; import OptimisticL2OutputRoots from './OptimisticL2OutputRoots';
const test = base.extend({ test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
const OUTPUT_ROOTS_API_URL = buildApiUrl('optimistic_l2_output_roots');
const OUTPUT_ROOTS_COUNT_API_URL = buildApiUrl('optimistic_l2_output_roots_count');
test('base view +@mobile', async({ mount, page }) => {
// test on mobile is flaky // test on mobile is flaky
// my assumption is there is not enough time to calculate hashes truncation so component is unstable // my assumption is there is not enough time to calculate hashes truncation so component is unstable
// so I raised the test timeout to check if it helps // so I raised the test timeout to check if it helps
test.slow(); test.slow();
await mockEnvs(ENVS_MAP.optimisticRollup);
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ await mockTextAd();
status: 200, await mockApiResponse('optimistic_l2_output_roots', outputRootsData);
body: '', await mockApiResponse('optimistic_l2_output_roots_count', 9927);
})); const component = await render(<OptimisticL2OutputRoots/>);
await page.route(OUTPUT_ROOTS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(outputRootsData),
}));
await page.route(OUTPUT_ROOTS_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '9927',
}));
const component = await mount(
<TestApp>
<OptimisticL2OutputRoots/>
</TestApp>,
);
await expect(component).toHaveScreenshot({ timeout: 10_000 }); await expect(component).toHaveScreenshot({ timeout: 10_000 });
}); });
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { txnBatchesData } from 'mocks/l2txnBatches/txnBatches'; import { txnBatchesData } from 'mocks/l2txnBatches/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import OptimisticL2TxnBatches from './OptimisticL2TxnBatches'; import OptimisticL2TxnBatches from './OptimisticL2TxnBatches';
const test = base.extend({ test('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
const TXN_BATCHES_API_URL = buildApiUrl('optimistic_l2_txn_batches');
const TXN_BATCHES_COUNT_API_URL = buildApiUrl('optimistic_l2_txn_batches_count');
test('base view +@mobile', async({ mount, page }) => {
// test on mobile is flaky // test on mobile is flaky
// my assumption is there is not enough time to calculate hashes truncation so component is unstable // my assumption is there is not enough time to calculate hashes truncation so component is unstable
// so I raised the test timeout to check if it helps // so I raised the test timeout to check if it helps
test.slow(); test.slow();
await mockTextAd();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ await mockEnvs(ENVS_MAP.optimisticRollup);
status: 200, await mockApiResponse('optimistic_l2_txn_batches', txnBatchesData);
body: '', await mockApiResponse('optimistic_l2_txn_batches_count', 1235016);
})); const component = await render(<OptimisticL2TxnBatches/>);
await page.route(TXN_BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchesData),
}));
await page.route(TXN_BATCHES_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '1235016',
}));
const component = await mount(
<TestApp>
<OptimisticL2TxnBatches/>
</TestApp>,
);
await expect(component).toHaveScreenshot({ timeout: 10_000 }); await expect(component).toHaveScreenshot({ timeout: 10_000 });
}); });
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { data as withdrawalsData } from 'mocks/l2withdrawals/withdrawals'; import { data as withdrawalsData } from 'mocks/l2withdrawals/withdrawals';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import OptimisticL2Withdrawals from './OptimisticL2Withdrawals'; import OptimisticL2Withdrawals from './OptimisticL2Withdrawals';
const test = base.extend({ test('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
const WITHDRAWALS_API_URL = buildApiUrl('optimistic_l2_withdrawals');
const WITHDRAWALS_COUNT_API_URL = buildApiUrl('optimistic_l2_withdrawals_count');
test('base view +@mobile', async({ mount, page }) => {
// test on mobile is flaky // test on mobile is flaky
// my assumption is there is not enough time to calculate hashes truncation so component is unstable // my assumption is there is not enough time to calculate hashes truncation so component is unstable
// so I raised the test timeout to check if it helps // so I raised the test timeout to check if it helps
test.slow(); test.slow();
await mockTextAd();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ await mockEnvs(ENVS_MAP.optimisticRollup);
status: 200, await mockApiResponse('optimistic_l2_withdrawals', withdrawalsData);
body: '', await mockApiResponse('optimistic_l2_withdrawals_count', 397);
})); const component = await render(<OptimisticL2Withdrawals/>);
await page.route(WITHDRAWALS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withdrawalsData),
}));
await page.route(WITHDRAWALS_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '397',
}));
const component = await mount(
<TestApp>
<OptimisticL2Withdrawals/>
</TestApp>,
);
await expect(component).toHaveScreenshot({ timeout: 10_000 }); await expect(component).toHaveScreenshot({ timeout: 10_000 });
}); });
...@@ -247,7 +247,7 @@ const TokenPageContent = () => { ...@@ -247,7 +247,7 @@ const TokenPageContent = () => {
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery }/> <TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery } hash={ hashString }/>
<TokenDetails tokenQuery={ tokenQuery }/> <TokenDetails tokenQuery={ tokenQuery }/>
......
...@@ -10,7 +10,7 @@ import getQueryParamString from 'lib/router/getQueryParamString'; ...@@ -10,7 +10,7 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import { publicClient } from 'lib/web3/client'; import { publicClient } from 'lib/web3/client';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError'; import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
...@@ -77,7 +77,7 @@ const TransactionPageContent = () => { ...@@ -77,7 +77,7 @@ const TransactionPageContent = () => {
const tags = ( const tags = (
<EntityTags <EntityTags
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
tagsBefore={ [ data?.tx_tag ? { label: data.tx_tag, display_name: data.tx_tag } : undefined ] } tags={ data?.tx_tag ? [ { slug: data.tx_tag, name: data.tx_tag, tagType: 'private_tag' as const } ] : [] }
/> />
); );
......
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { userOpData } from 'mocks/userOps/userOp'; import { userOpData } from 'mocks/userOps/userOp';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect, devices } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import UserOp from './UserOp'; import UserOp from './UserOp';
const test = base.extend({ const hooksConfig = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any router: {
context: contextWithEnvs(configs.featureEnvs.userOps) as any, query: { hash: userOpData.hash },
}); isReady: true,
},
const USER_OP_API_URL = buildApiUrl('user_op', { hash: userOpData.hash }); };
test('base view', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpData),
}));
const component = await mount( test.beforeEach(async({ mockEnvs }) => {
<TestApp> await mockEnvs(ENVS_MAP.userOps);
<UserOp/> });
</TestApp>,
{ hooksConfig: {
router: {
query: { hash: userOpData.hash },
isReady: true,
},
} },
);
test('base view', async({ render, mockTextAd, mockApiResponse }) => {
await mockTextAd();
await mockApiResponse('user_op', userOpData, { pathParams: { hash: userOpData.hash } });
const component = await render(<UserOp/>, { hooksConfig });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test.describe('mobile', () => { test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport }); test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page }) => { test('base view', async({ render, mockTextAd, mockApiResponse }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ await mockTextAd();
status: 200, await mockApiResponse('user_op', userOpData, { pathParams: { hash: userOpData.hash } });
body: '', const component = await render(<UserOp/>, { hooksConfig });
}));
await page.route(USER_OP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpData),
}));
const component = await mount(
<TestApp>
<UserOp/>
</TestApp>,
{ hooksConfig: {
router: {
query: { hash: userOpData.hash },
isReady: true,
},
} },
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
}); });
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { userOpsData } from 'mocks/userOps/userOps'; import { userOpsData } from 'mocks/userOps/userOps';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import UserOps from './UserOps'; import UserOps from './UserOps';
const test = base.extend({ test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.userOps);
context: contextWithEnvs(configs.featureEnvs.userOps) as any, await mockTextAd();
}); await mockApiResponse('user_ops', userOpsData);
const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <UserOps/> </Box>);
const USER_OPS_API_URL = buildApiUrl('user_ops');
test('base view +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OPS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpsData),
}));
const component = await mount(
<TestApp>
<Box pt={{ base: '106px', lg: 0 }}>
<UserOps/>
</Box>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { txnBatchData } from 'mocks/zkEvm/txnBatches'; import { txnBatchData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect, devices } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import ZkEvmL2TxnBatch from './ZkEvmL2TxnBatch'; import ZkEvmL2TxnBatch from './ZkEvmL2TxnBatch';
const test = base.extend({ const batchNumber = '5';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any,
});
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { number: '5' }, query: { number: batchNumber },
}, },
}; };
const BATCH_API_URL = buildApiUrl('zkevm_l2_txn_batch', { number: '5' }); test.beforeEach(async({ mockTextAd, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.zkEvmRollup);
await mockTextAd();
await mockApiResponse('zkevm_l2_txn_batch', txnBatchData, { pathParams: { number: batchNumber } });
});
test('base view', async({ mount, page }) => { test('base view', async({ render }) => {
test.slow(); test.slow();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ const component = await render(<ZkEvmL2TxnBatch/>, { hooksConfig });
status: 200,
body: '',
}));
await page.route(BATCH_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchData),
}));
const component = await mount(
<TestApp>
<ZkEvmL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test.describe('mobile', () => { test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport }); test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page }) => { test('base view', async({ render }) => {
test.slow(); test.slow();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ const component = await render(<ZkEvmL2TxnBatch/>, { hooksConfig });
status: 200,
body: '',
}));
await page.route(BATCH_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchData),
}));
const component = await mount(
<TestApp>
<ZkEvmL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
}); });
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { txnBatchesData } from 'mocks/zkEvm/txnBatches'; import { txnBatchesData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import ZkEvmL2TxnBatches from './ZkEvmL2TxnBatches'; import ZkEvmL2TxnBatches from './ZkEvmL2TxnBatches';
const test = base.extend({ test('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.zkEvmRollup);
context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any, await mockTextAd();
}); await mockApiResponse('zkevm_l2_txn_batches', txnBatchesData);
await mockApiResponse('zkevm_l2_txn_batches_count', 9927);
const BATCHES_API_URL = buildApiUrl('zkevm_l2_txn_batches'); const component = await render(<ZkEvmL2TxnBatches/>);
const BATCHES_COUNTERS_API_URL = buildApiUrl('zkevm_l2_txn_batches_count');
test('base view +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchesData),
}));
await page.route(BATCHES_COUNTERS_API_URL, (route) => route.fulfill({
status: 200,
body: '9927',
}));
const component = await mount(
<TestApp>
<ZkEvmL2TxnBatches/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as zkSyncTxnBatchMock from 'mocks/zkSync/zkSyncTxnBatch'; import * as zkSyncTxnBatchMock from 'mocks/zkSync/zkSyncTxnBatch';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect, devices } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import ZkSyncL2TxnBatch from './ZkSyncL2TxnBatch'; import ZkSyncL2TxnBatch from './ZkSyncL2TxnBatch';
const test = base.extend({ const batchNumber = String(zkSyncTxnBatchMock.base.number);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkSyncRollup) as any,
});
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { number: String(zkSyncTxnBatchMock.base.number) }, query: { number: batchNumber },
}, },
}; };
test.beforeEach(async({ page }) => { test.beforeEach(async({ mockTextAd, mockApiResponse, mockEnvs }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ await mockEnvs(ENVS_MAP.zkSyncRollup);
status: 200, await mockTextAd();
body: '', await mockApiResponse('zksync_l2_txn_batch', zkSyncTxnBatchMock.base, { pathParams: { number: batchNumber } });
}));
await page.route(BATCH_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(zkSyncTxnBatchMock.base),
}));
}); });
const BATCH_API_URL = buildApiUrl('zksync_l2_txn_batch', { number: String(zkSyncTxnBatchMock.base.number) }); test('base view', async({ render }) => {
const component = await render(<ZkSyncL2TxnBatch/>, { hooksConfig });
test('base view', async({ mount }) => {
const component = await mount(
<TestApp>
<ZkSyncL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test.describe('mobile', () => { test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport }); test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount }) => { test('base view', async({ render }) => {
const component = await mount( const component = await render(<ZkSyncL2TxnBatch/>, { hooksConfig });
<TestApp>
<ZkSyncL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
}); });
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as zkSyncTxnBatchesMock from 'mocks/zkSync/zkSyncTxnBatches'; import * as zkSyncTxnBatchesMock from 'mocks/zkSync/zkSyncTxnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import ZkSyncL2TxnBatches from './ZkSyncL2TxnBatches'; import ZkSyncL2TxnBatches from './ZkSyncL2TxnBatches';
const test = base.extend({ test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkSyncRollup) as any,
});
const BATCHES_API_URL = buildApiUrl('zksync_l2_txn_batches');
const BATCHES_COUNTERS_API_URL = buildApiUrl('zksync_l2_txn_batches_count');
test('base view +@mobile', async({ mount, page }) => {
test.slow(); test.slow();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ await mockEnvs(ENVS_MAP.zkSyncRollup);
status: 200, await mockTextAd();
body: '', await mockApiResponse('zksync_l2_txn_batches', zkSyncTxnBatchesMock.baseResponse);
})); await mockApiResponse('zksync_l2_txn_batches_count', 9927);
await page.route(BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(zkSyncTxnBatchesMock.baseResponse),
}));
await page.route(BATCHES_COUNTERS_API_URL, (route) => route.fulfill({
status: 200,
body: '9927',
}));
const component = await mount(
<TestApp>
<ZkSyncL2TxnBatches/>
</TestApp>,
);
const component = await render(<ZkSyncL2TxnBatches/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -7,9 +7,10 @@ export interface Props { ...@@ -7,9 +7,10 @@ export interface Props {
text: string; text: string;
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
onClick?: (event: React.MouseEvent) => void;
} }
const CopyToClipboard = ({ text, className, isLoading }: Props) => { const CopyToClipboard = ({ text, className, isLoading, onClick }: Props) => {
const { hasCopied, onCopy } = useClipboard(text, 1000); const { hasCopied, onCopy } = useClipboard(text, 1000);
const [ copied, setCopied ] = useState(false); const [ copied, setCopied ] = useState(false);
// have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107 // have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
...@@ -24,6 +25,11 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => { ...@@ -24,6 +25,11 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
} }
}, [ hasCopied ]); }, [ hasCopied ]);
const handleClick = React.useCallback((event: React.MouseEvent) => {
onCopy();
onClick?.(event);
}, [ onClick, onCopy ]);
if (isLoading) { if (isLoading) {
return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 } display="inline-block"/>; return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 } display="inline-block"/>;
} }
...@@ -39,7 +45,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => { ...@@ -39,7 +45,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
variant="simple" variant="simple"
display="inline-block" display="inline-block"
flexShrink={ 0 } flexShrink={ 0 }
onClick={ onCopy } onClick={ handleClick }
className={ className } className={ className }
onMouseEnter={ onOpen } onMouseEnter={ onOpen }
onMouseLeave={ onClose } onMouseLeave={ onClose }
......
...@@ -16,23 +16,21 @@ const EmptySearchResult = ({ text }: Props) => { ...@@ -16,23 +16,21 @@ const EmptySearchResult = ({ text }: Props) => {
display="flex" display="flex"
flexDirection="column" flexDirection="column"
alignItems="center" alignItems="center"
justifyContent="center"
mt="50px"
> >
<Icon as={ emptySearchResultIcon } boxSize={ 60 }/> <Icon
as={ emptySearchResultIcon }
w={{ base: '160px', sm: '240px' }}
h="auto"
mb={{ base: 4, sm: 6 }}
/>
<Heading <Heading as="h4" size="sm" mb={ 2 }>
as="h3"
marginBottom={ 2 }
fontSize={{ base: '2xl', sm: '3xl' }}
fontWeight="semibold"
>
No results No results
</Heading> </Heading>
<Text <Text fontSize={{ base: 'sm', sm: 'md' }} align="center">
fontSize={{ base: 'sm' }}
variant="secondary"
align="center"
>
{ text } { text }
</Text> </Text>
</Box> </Box>
......
import type { ThemingProps } from '@chakra-ui/react';
import { Flex, chakra, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody, Box } from '@chakra-ui/react';
import React from 'react';
import type { UserTags } from 'types/api/addressParams';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
interface TagData {
label: string;
display_name: string;
colorScheme?: ThemingProps<'Tag'>['colorScheme'];
variant?: ThemingProps<'Tag'>['variant'];
}
interface Props {
className?: string;
data?: UserTags;
isLoading?: boolean;
tagsBefore?: Array<TagData | undefined>;
tagsAfter?: Array<TagData | undefined>;
contentAfter?: React.ReactNode;
}
const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoading, contentAfter }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
const tags: Array<TagData> = [
...tagsBefore,
...(data?.private_tags || []),
...(data?.public_tags || []),
...(data?.watchlist_names || []),
...tagsAfter,
]
.filter(Boolean);
const metaSuitesPlaceholder = config.features.metasuites.isEnabled ?
<Box display="none" id="meta-suites__address-tag" data-ready={ !isLoading }/> :
null;
if (tags.length === 0 && !contentAfter) {
return metaSuitesPlaceholder;
}
const content = (() => {
if (isMobile && tags.length > 2) {
return (
<>
{
tags
.slice(0, 2)
.map((tag) => (
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
}
{ metaSuitesPlaceholder }
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag isLoading={ isLoading }onClick={ onToggle }>+{ tags.length - 1 }</Tag>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{
tags
.slice(2)
.map((tag) => (
<Tag
key={ tag.label }
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
}
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return (
<>
{ tags.map((tag) => (
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
)) }
{ metaSuitesPlaceholder }
</>
);
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
{ contentAfter }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as addressMetadataMock from 'mocks/metadata/address';
import { test, expect } from 'playwright/lib';
import EntityTag from './EntityTag';
test.use({ viewport: { width: 400, height: 300 } });
test('custom name tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.customNameTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('generic tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.genericTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('protocol tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.protocolTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('tag with link and long name +@dark-mode', async({ render }) => {
const component = await render(<EntityTag data={ addressMetadataMock.infoTagWithLink } truncate/>);
await expect(component).toHaveScreenshot();
});
test('tag with tooltip +@dark-mode', async({ render, page, mockAssetResponse }) => {
await mockAssetResponse(addressMetadataMock.tagWithTooltip.meta?.tooltipIcon as string, './playwright/mocks/image_s.jpg');
const component = await render(<EntityTag data={ addressMetadataMock.tagWithTooltip }/>);
await component.getByText('BlockscoutHeroes').hover();
await page.getByText('Blockscout team member').waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot();
});
import { chakra, Skeleton, Tag } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import IconSvg from 'ui/shared/IconSvg';
import TruncatedValue from 'ui/shared/TruncatedValue';
import EntityTagLink from './EntityTagLink';
import EntityTagPopover from './EntityTagPopover';
interface Props {
data: TEntityTag;
isLoading?: boolean;
truncate?: boolean;
}
const EntityTag = ({ data, isLoading, truncate }: Props) => {
if (isLoading) {
return <Skeleton borderRadius="sm" w="100px" h="24px"/>;
}
// const hasLink = Boolean(data.meta?.tagUrl || data.tagType === 'generic' || data.tagType === 'protocol');
// Change the condition when "Tag search" page is ready - issue #1869
const hasLink = Boolean(data.meta?.tagUrl);
const iconColor = data.meta?.textColor ?? 'gray.400';
return (
<EntityTagPopover data={ data }>
<Tag
display="flex"
alignItems="center"
minW={ 0 }
maxW={ truncate ? { base: '125px', lg: '300px' } : undefined }
bg={ data.meta?.bgColor }
color={ data.meta?.textColor }
colorScheme={ hasLink ? 'gray-blue' : 'gray' }
_hover={ hasLink ? { opacity: 0.76 } : undefined }
>
<EntityTagLink data={ data }>
{ data.tagType === 'name' && <IconSvg name="publictags_slim" boxSize={ 3 } mr={ 1 } flexShrink={ 0 } color={ iconColor }/> }
{ (data.tagType === 'protocol' || data.tagType === 'generic') && <chakra.span color={ iconColor } whiteSpace="pre"># </chakra.span> }
<TruncatedValue value={ data.name } tooltipPlacement="top"/>
</EntityTagLink>
</Tag>
</EntityTagPopover>
);
};
export default React.memo(EntityTag);
import type { LinkProps } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag } from './types';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/LinkExternal';
// import { route } from 'nextjs-routes';
// import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
data: EntityTag;
children: React.ReactNode;
}
const EntityTagLink = ({ data, children }: Props) => {
const handleLinkClick = React.useCallback(() => {
if (!data.meta?.tagUrl) {
return;
}
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, {
Type: 'Address tag',
Info: data.slug,
URL: data.meta.tagUrl,
});
}, [ data.meta?.tagUrl, data.slug ]);
const linkProps: LinkProps = {
color: 'inherit',
display: 'inline-flex',
overflow: 'hidden',
_hover: { textDecor: 'none', color: 'inherit' },
onClick: handleLinkClick,
};
// Uncomment this block when "Tag search" page is ready - issue #1869
// switch (data.tagType) {
// case 'generic':
// case 'protocol': {
// return (
// <LinkInternal
// { ...linkProps }
// href={ route({ pathname: '/' }) }
// >
// { children }
// </LinkInternal>
// );
// }
// }
if (data.meta?.tagUrl) {
return (
<LinkExternal
{ ...linkProps }
href={ data.meta.tagUrl }
iconColor={ data.meta.textColor }
>
{ children }
</LinkExternal>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
export default React.memo(EntityTagLink);
import { chakra, Image, Flex, Popover, PopoverArrow, PopoverBody, PopoverContent, PopoverTrigger, useColorModeValue, DarkMode } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag } from './types';
import makePrettyLink from 'lib/makePrettyLink';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
data: EntityTag;
children: React.ReactNode;
}
const EntityTagPopover = ({ data, children }: Props) => {
const bgColor = useColorModeValue('gray.700', 'gray.900');
const link = makePrettyLink(data.meta?.tooltipUrl);
const hasPopover = Boolean(data.meta?.tooltipIcon || data.meta?.tooltipTitle || data.meta?.tooltipDescription || data.meta?.tooltipUrl);
const handleLinkClick = React.useCallback(() => {
if (!data.meta?.tooltipUrl) {
return;
}
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, {
Type: 'Address tag',
Info: data.slug,
URL: data.meta.tooltipUrl,
});
}, [ data.meta?.tooltipUrl, data.slug ]);
if (!hasPopover) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
}
return (
<Popover trigger="hover" isLazy>
<PopoverTrigger>
{ children }
</PopoverTrigger>
<PopoverContent bgColor={ bgColor } borderRadius="sm">
<PopoverArrow bgColor={ bgColor }/>
<DarkMode>
<PopoverBody color="white" p={ 2 } fontSize="sm" display="flex" flexDir="column" rowGap={ 2 }>
{ (data.meta?.tooltipIcon || data.meta?.tooltipTitle) && (
<Flex columnGap={ 3 } alignItems="center">
{ data.meta?.tooltipIcon && <Image src={ data.meta.tooltipIcon } boxSize="30px" alt={ `${ data.name } tag logo` }/> }
{ data.meta?.tooltipTitle && <chakra.span fontWeight="600">{ data.meta.tooltipTitle }</chakra.span> }
</Flex>
) }
{ data.meta?.tooltipDescription && <chakra.span>{ data.meta.tooltipDescription }</chakra.span> }
{ link && <LinkExternal href={ link.url } onClick={ handleLinkClick }>{ link.domain }</LinkExternal> }
</PopoverBody>
</DarkMode>
</PopoverContent>
</Popover>
);
};
export default React.memo(EntityTagPopover);
import { Box, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, chakra } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
import EntityTag from './EntityTag';
interface Props {
className?: string;
tags: Array<TEntityTag>;
isLoading?: boolean;
}
const EntityTags = ({ tags, className, isLoading }: Props) => {
const isMobile = useIsMobile();
const visibleNum = isMobile ? 2 : 3;
const metaSuitesPlaceholder = config.features.metasuites.isEnabled ?
<Box display="none" id="meta-suites__address-tag" data-ready={ !isLoading }/> :
null;
if (tags.length === 0) {
return metaSuitesPlaceholder;
}
const content = (() => {
if (tags.length > visibleNum) {
return (
<>
{ tags.slice(0, visibleNum).map((tag) => <EntityTag key={ tag.slug } data={ tag } isLoading={ isLoading } truncate/>) }
{ metaSuitesPlaceholder }
<Popover trigger="click" placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag isLoading={ isLoading } cursor="pointer" as="button" _hover={{ color: 'link_hovered' }}>
+{ tags.length - visibleNum }
</Tag>
</PopoverTrigger>
<PopoverContent w="300px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{ tags.slice(visibleNum).map((tag) => <EntityTag key={ tag.slug } data={ tag }/>) }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return (
<>
{ tags.map((tag) => <EntityTag key={ tag.slug } data={ tag } isLoading={ isLoading } truncate/>) }
{ metaSuitesPlaceholder }
</>
);
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import type { EntityTag } from './types';
import type { UserTags } from 'types/api/addressParams';
export default function formatUserTags(data: UserTags | undefined): Array<EntityTag> {
return [
...(data?.private_tags || []).map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'private_tag' as const, ordinal: 1_000 })),
...(data?.watchlist_names || []).map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'watchlist' as const, ordinal: 1_000 })),
];
}
import type { EntityTag } from './types';
export default function sortEntityTags(tagA: EntityTag, tagB: EntityTag): number {
if (tagA.ordinal < tagB.ordinal) {
return 1;
}
if (tagA.ordinal > tagB.ordinal) {
return -1;
}
return 0;
}
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
export type EntityTagType = AddressMetadataTagType | 'custom' | 'watchlist' | 'private_tag';
export interface EntityTag extends Pick<AddressMetadataTagFormatted, 'slug' | 'name' | 'ordinal'> {
tagType: EntityTagType;
meta?: AddressMetadataTagFormatted['meta'];
}
import type { ChakraProps } from '@chakra-ui/react'; import type { ChakraProps, LinkProps } from '@chakra-ui/react';
import { Link, chakra, Box, Skeleton, useColorModeValue } from '@chakra-ui/react'; import { Link, chakra, Box, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -10,10 +10,11 @@ interface Props { ...@@ -10,10 +10,11 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
variant?: 'subtle'; variant?: 'subtle';
onClick?: () => void; iconColor?: LinkProps['color'];
onClick?: LinkProps['onClick'];
} }
const LinkExternal = ({ href, children, className, isLoading, variant, onClick }: Props) => { const LinkExternal = ({ href, children, className, isLoading, variant, iconColor, onClick }: Props) => {
const subtleLinkBg = useColorModeValue('gray.100', 'gray.700'); const subtleLinkBg = useColorModeValue('gray.100', 'gray.700');
const styleProps: ChakraProps = (() => { const styleProps: ChakraProps = (() => {
...@@ -60,7 +61,7 @@ const LinkExternal = ({ href, children, className, isLoading, variant, onClick } ...@@ -60,7 +61,7 @@ const LinkExternal = ({ href, children, className, isLoading, variant, onClick }
return ( return (
<Link className={ className } { ...styleProps } target="_blank" href={ href } onClick={ onClick }> <Link className={ className } { ...styleProps } target="_blank" href={ href } onClick={ onClick }>
{ children } { children }
<IconSvg name="arrows/north-east" boxSize={ 4 } verticalAlign="middle" color="gray.400" flexShrink={ 0 }/> <IconSvg name="arrows/north-east" boxSize={ 4 } verticalAlign="middle" color={ iconColor ?? 'gray.400' } flexShrink={ 0 }/>
</Link> </Link>
); );
}; };
......
...@@ -5,7 +5,7 @@ import type { TokenInfo } from 'types/api/token'; ...@@ -5,7 +5,7 @@ import type { TokenInfo } from 'types/api/token';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
...@@ -34,8 +34,8 @@ const DefaultView = () => { ...@@ -34,8 +34,8 @@ const DefaultView = () => {
<> <>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/> <IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
<EntityTags <EntityTags
tagsBefore={ [ tags={ [
{ label: 'example', display_name: 'Example label' }, { slug: 'example', name: 'Example label', tagType: 'custom' },
] } ] }
flexGrow={ 1 } flexGrow={ 1 }
/> />
......
...@@ -5,7 +5,8 @@ import type { TokenInfo } from 'types/api/token'; ...@@ -5,7 +5,8 @@ import type { TokenInfo } from 'types/api/token';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag'; import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
...@@ -29,21 +30,19 @@ const LongNameAndManyTags = () => { ...@@ -29,21 +30,19 @@ const LongNameAndManyTags = () => {
<> <>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/> <IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/>
<EntityTags <EntityTags
data={{ tags={ [
private_tags: [ privateTag ], { slug: 'example', name: 'Example with long name', tagType: 'custom' },
public_tags: [ publicTag ], ...formatUserTags({
watchlist_names: [ watchlistName ], private_tags: [ privateTag ],
}} public_tags: [ publicTag ],
tagsBefore={ [ watchlist_names: [ watchlistName ],
{ label: 'example', display_name: 'Example with long name' }, }),
{ slug: 'after_1', name: 'Another tag', tagType: 'custom' },
{ slug: 'after_2', name: 'And yet more', tagType: 'custom' },
] } ] }
tagsAfter={ [
{ label: 'after_1', display_name: 'Another tag' },
{ label: 'after_2', display_name: 'And yet more' },
] }
contentAfter={ <NetworkExplorers type="token" pathParam="token-hash" ml="auto"/> }
flexGrow={ 1 } flexGrow={ 1 }
/> />
<NetworkExplorers type="token" pathParam="token-hash" ml="auto"/>
</> </>
); );
......
...@@ -52,8 +52,11 @@ const TabsWithScroll = ({ ...@@ -52,8 +52,11 @@ const TabsWithScroll = ({
}, [ tabs ]); }, [ tabs ]);
const handleTabChange = React.useCallback((index: number) => { const handleTabChange = React.useCallback((index: number) => {
if (isLoading) {
return;
}
onTabChange ? onTabChange(index) : setActiveTabIndex(index); onTabChange ? onTabChange(index) : setActiveTabIndex(index);
}, [ onTabChange ]); }, [ isLoading, onTabChange ]);
useEffect(() => { useEffect(() => {
if (defaultTabIndex !== undefined) { if (defaultTabIndex !== undefined) {
......
import type { PlacementWithLogical } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import React from 'react'; import React from 'react';
...@@ -8,9 +9,10 @@ import { BODY_TYPEFACE } from 'theme/foundations/typography'; ...@@ -8,9 +9,10 @@ import { BODY_TYPEFACE } from 'theme/foundations/typography';
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
label: string; label: string;
placement?: PlacementWithLogical;
} }
const TruncatedTextTooltip = ({ children, label }: Props) => { const TruncatedTextTooltip = ({ children, label, placement }: Props) => {
const childRef = React.useRef<HTMLElement>(null); const childRef = React.useRef<HTMLElement>(null);
const [ isTruncated, setTruncated ] = React.useState(false); const [ isTruncated, setTruncated ] = React.useState(false);
...@@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => { ...@@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => {
); );
if (isTruncated) { if (isTruncated) {
return <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>{ modifiedChildren }</Tooltip>; return <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }} placement={ placement }>{ modifiedChildren }</Tooltip>;
} }
return modifiedChildren; return modifiedChildren;
......
import type { PlacementWithLogical } from '@chakra-ui/react';
import { Skeleton, chakra } from '@chakra-ui/react'; import { Skeleton, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -7,11 +8,12 @@ interface Props { ...@@ -7,11 +8,12 @@ interface Props {
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
value: string; value: string;
tooltipPlacement?: PlacementWithLogical;
} }
const TruncatedValue = ({ className, isLoading, value }: Props) => { const TruncatedValue = ({ className, isLoading, value, tooltipPlacement }: Props) => {
return ( return (
<TruncatedTextTooltip label={ value }> <TruncatedTextTooltip label={ value } placement={ tooltipPlacement }>
<Skeleton <Skeleton
className={ className } className={ className }
isLoaded={ !isLoading } isLoaded={ !isLoading }
......
import { Flex, chakra } from '@chakra-ui/react'; import { Flex, chakra } from '@chakra-ui/react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import React from 'react'; import React from 'react';
import { useAccount } from 'wagmi';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useAccount from 'lib/web3/useAccount';
import Web3ModalProvider from '../Web3ModalProvider';
const GetitAdPlugin = dynamic(() => import('getit-sdk').then(module => module.GetitAdPlugin), { ssr: false }); const GetitAdPlugin = dynamic(() => import('getit-sdk').then(module => module.GetitAdPlugin), { ssr: false });
const GETIT_API_KEY = 'ZmGXVvwYUAW4yXL8RzWQHNKmpSyQmt3TDXsXUxqFqXPdoaiSSFyca3BOyunDcWdyOwTkX3UVVQel28qbjoOoWPxYVpPdNzbUNkAHyFyJX7Lk9TVcPDZKTQmwHlSMzO3a'; const GETIT_API_KEY = 'ZmGXVvwYUAW4yXL8RzWQHNKmpSyQmt3TDXsXUxqFqXPdoaiSSFyca3BOyunDcWdyOwTkX3UVVQel28qbjoOoWPxYVpPdNzbUNkAHyFyJX7Lk9TVcPDZKTQmwHlSMzO3a';
const GetitBannerContent = ({ address, className }: { address?: string; className?: string }) => { const GetitBanner = ({ className }: { className?: string }) => {
const isMobile = Boolean(useIsMobile()); const isMobile = Boolean(useIsMobile());
const { address } = useAccount();
return ( return (
<Flex className={ className } h="90px"> <Flex className={ className } h="90px">
...@@ -27,22 +26,4 @@ const GetitBannerContent = ({ address, className }: { address?: string; classNam ...@@ -27,22 +26,4 @@ const GetitBannerContent = ({ address, className }: { address?: string; classNam
); );
}; };
const GetitBannerWithWalletAddress = ({ className }: { className?: string }) => {
const { address } = useAccount();
return <GetitBannerContent address={ address } className={ className }/>;
};
const GetitBanner = ({ className }: { className?: string }) => {
const fallback = React.useCallback(() => {
return <GetitBannerContent className={ className }/>;
}, [ className ]);
return (
<Web3ModalProvider fallback={ fallback }>
<GetitBannerWithWalletAddress className={ className }/>
</Web3ModalProvider>
);
};
export default chakra(GetitBanner); export default chakra(GetitBanner);
...@@ -2,15 +2,22 @@ import { Flex, chakra } from '@chakra-ui/react'; ...@@ -2,15 +2,22 @@ import { Flex, chakra } from '@chakra-ui/react';
import { Banner, setWalletAddresses } from '@hypelab/sdk-react'; import { Banner, setWalletAddresses } from '@hypelab/sdk-react';
import Script from 'next/script'; import Script from 'next/script';
import React from 'react'; import React from 'react';
import { useAccount } from 'wagmi';
import Web3ModalProvider from '../Web3ModalProvider'; import useAccount from 'lib/web3/useAccount';
import { hypeInit } from './hypeBannerScript'; import { hypeInit } from './hypeBannerScript';
const DESKTOP_BANNER_SLUG = 'b1559fc3e7'; const DESKTOP_BANNER_SLUG = 'b1559fc3e7';
const MOBILE_BANNER_SLUG = '668ed80a9e'; const MOBILE_BANNER_SLUG = '668ed80a9e';
const HypeBannerContent = ({ className }: { className?: string }) => { const HypeBanner = ({ className }: { className?: string }) => {
const { address } = useAccount();
React.useEffect(() => {
if (address) {
setWalletAddresses([ address ]);
}
}, [ address ]);
return ( return (
<> <>
...@@ -28,28 +35,4 @@ const HypeBannerContent = ({ className }: { className?: string }) => { ...@@ -28,28 +35,4 @@ const HypeBannerContent = ({ className }: { className?: string }) => {
); );
}; };
const HypeBannerWithWalletAddress = ({ className }: { className?: string }) => {
const { address } = useAccount();
React.useEffect(() => {
if (address) {
setWalletAddresses([ address ]);
}
}, [ address ]);
return <HypeBannerContent className={ className }/>;
};
const HypeBanner = ({ className }: { className?: string }) => {
const fallback = React.useCallback(() => {
return <HypeBannerContent className={ className }/>;
}, [ className ]);
return (
<Web3ModalProvider fallback={ fallback }>
<HypeBannerWithWalletAddress className={ className }/>
</Web3ModalProvider>
);
};
export default chakra(HypeBanner); export default chakra(HypeBanner);
...@@ -4,7 +4,7 @@ import React from 'react'; ...@@ -4,7 +4,7 @@ import React from 'react';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props extends TagProps { export interface Props extends TagProps {
isLoading?: boolean; isLoading?: boolean;
} }
......
...@@ -97,6 +97,18 @@ test('with ENS', async({ mount }) => { ...@@ -97,6 +97,18 @@ test('with ENS', async({ mount }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with name tag', async({ mount }) => {
const component = await mount(
<TestApp>
<AddressEntity
address={ addressMock.withNameTag }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('external link', async({ mount }) => { test('external link', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
......
...@@ -100,11 +100,13 @@ const Icon = (props: IconProps) => { ...@@ -100,11 +100,13 @@ const Icon = (props: IconProps) => {
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>; type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>;
const Content = chakra((props: ContentProps) => { const Content = chakra((props: ContentProps) => {
if (props.address.name || props.address.ens_domain_name) { const nameTag = props.address.metadata?.tags.find(tag => tag.tagType === 'name')?.name;
const text = props.address.ens_domain_name || props.address.name; const nameText = nameTag || props.address.ens_domain_name || props.address.name;
if (nameText) {
const label = ( const label = (
<VStack gap={ 0 } py={ 1 } color="inherit"> <VStack gap={ 0 } py={ 1 } color="inherit">
<Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ text }</Box> <Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ nameText }</Box>
<Box whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.hash }</Box> <Box whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.hash }</Box>
</VStack> </VStack>
); );
...@@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => { ...@@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => {
return ( return (
<Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}> <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>
<Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span"> <Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span">
{ text } { nameText }
</Skeleton> </Skeleton>
</Tooltip> </Tooltip>
); );
...@@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => { ...@@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => {
const Container = EntityBase.Container; const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps { export interface EntityProps extends EntityBase.EntityBaseProps {
address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name' | 'ens_domain_name'>; address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name' | 'ens_domain_name' | 'metadata'>;
isSafeAddress?: boolean; isSafeAddress?: boolean;
} }
......
...@@ -11,7 +11,7 @@ const LayoutDefault = ({ children }: Props) => { ...@@ -11,7 +11,7 @@ const LayoutDefault = ({ children }: Props) => {
return ( return (
<Layout.Container <Layout.Container
overflowY="hidden" overflowY="hidden"
height={ [ '-webkit-fill-available', '100vh' ] } height="$100vh"
display="flex" display="flex"
flexDirection="column" flexDirection="column"
> >
......
import { Divider, Flex, Skeleton, VStack } from '@chakra-ui/react'; import { Divider, Flex, Skeleton, VStack } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import Tag from 'ui/shared/chakra/Tag';
interface Props { interface Props {
methodId: string; methodId: string;
methodCall: string; methodCall: string;
isLoading?: boolean; isLoading?: boolean;
} }
const Item = ({ label, text, isLoading }: { label: string; text: string; isLoading?: boolean}) => { const Item = ({ label, children, isLoading }: { label: string; children: React.ReactNode; isLoading?: boolean}) => {
return ( return (
<Flex <Flex
columnGap={ 5 } columnGap={ 5 }
...@@ -19,7 +21,7 @@ const Item = ({ label, text, isLoading }: { label: string; text: string; isLoadi ...@@ -19,7 +21,7 @@ const Item = ({ label, text, isLoading }: { label: string; text: string; isLoadi
<Skeleton fontWeight={ 600 } w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } isLoaded={ !isLoading }> <Skeleton fontWeight={ 600 } w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } isLoaded={ !isLoading }>
{ label } { label }
</Skeleton > </Skeleton >
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">{ text }</Skeleton> { children }
</Flex> </Flex>
); );
}; };
...@@ -32,8 +34,12 @@ const LogDecodedInputDataHeader = ({ methodId, methodCall, isLoading }: Props) = ...@@ -32,8 +34,12 @@ const LogDecodedInputDataHeader = ({ methodId, methodCall, isLoading }: Props) =
fontSize="sm" fontSize="sm"
lineHeight={ 5 } lineHeight={ 5 }
> >
<Item label="Method id" text={ methodId } isLoading={ isLoading }/> <Item label="Method id" isLoading={ isLoading }>
<Item label="Call" text={ methodCall } isLoading={ isLoading }/> <Tag isLoading={ isLoading }>{ methodId }</Tag>
</Item>
<Item label="Call" isLoading={ isLoading }>
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">{ methodCall }</Skeleton>
</Item>
</VStack> </VStack>
); );
}; };
......
...@@ -11,10 +11,6 @@ import WalletMenuMobile from 'ui/snippets/walletMenu/WalletMenuMobile'; ...@@ -11,10 +11,6 @@ import WalletMenuMobile from 'ui/snippets/walletMenu/WalletMenuMobile';
import Burger from './Burger'; import Burger from './Burger';
const LOGO_IMAGE_PROPS = {
margin: '0 auto',
};
type Props = { type Props = {
hideSearchBar?: boolean; hideSearchBar?: boolean;
renderSearchBar?: () => React.ReactNode; renderSearchBar?: () => React.ReactNode;
...@@ -45,13 +41,12 @@ const HeaderMobile = ({ hideSearchBar, renderSearchBar }: Props) => { ...@@ -45,13 +41,12 @@ const HeaderMobile = ({ hideSearchBar, renderSearchBar }: Props) => {
bgColor={ bgColor } bgColor={ bgColor }
width="100%" width="100%"
alignItems="center" alignItems="center"
justifyContent="space-between"
transitionProperty="box-shadow" transitionProperty="box-shadow"
transitionDuration="slow" transitionDuration="slow"
boxShadow={ !inView && scrollDirection === 'down' ? 'md' : 'none' } boxShadow={ !inView && scrollDirection === 'down' ? 'md' : 'none' }
> >
<Burger/> <Burger/>
<NetworkLogo imageProps={ LOGO_IMAGE_PROPS }/> <NetworkLogo ml={ 2 } mr="auto"/>
<Flex columnGap={ 2 }> <Flex columnGap={ 2 }>
{ config.features.account.isEnabled ? <ProfileMenuMobile/> : <Box boxSize={ 10 }/> } { config.features.account.isEnabled ? <ProfileMenuMobile/> : <Box boxSize={ 10 }/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuMobile/> } { config.features.blockchainInteraction.isEnabled && <WalletMenuMobile/> }
......
...@@ -20,16 +20,17 @@ type Props = { ...@@ -20,16 +20,17 @@ type Props = {
px?: string | number; px?: string | number;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
disableActiveState?: boolean;
} }
const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => { const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const colors = useColors(); const colors = useColors();
const isExpanded = isCollapsed === false; const isExpanded = isCollapsed === false;
const isInternalLink = isInternalItem(item); const isInternalLink = isInternalItem(item);
const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalLink && item.isActive }); const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalLink && item.isActive && !disableActiveState });
const isXLScreen = useBreakpointValue({ base: false, xl: true }); const isXLScreen = useBreakpointValue({ base: false, xl: true });
const href = isInternalLink ? route(item.nextRoute) : item.url; const href = isInternalLink ? route(item.nextRoute) : item.url;
......
import type { StyleProps } from '@chakra-ui/react'; import { Box, Image, useColorModeValue, Skeleton, chakra } from '@chakra-ui/react';
import { Box, Image, useColorModeValue, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -10,10 +9,10 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -10,10 +9,10 @@ import IconSvg from 'ui/shared/IconSvg';
interface Props { interface Props {
isCollapsed?: boolean; isCollapsed?: boolean;
onClick?: (event: React.SyntheticEvent) => void; onClick?: (event: React.SyntheticEvent) => void;
imageProps?: StyleProps; className?: string;
} }
const LogoFallback = ({ isCollapsed, isSmall, imageProps }: { isCollapsed?: boolean; isSmall?: boolean; imageProps?: StyleProps }) => { const LogoFallback = ({ isCollapsed, isSmall }: { isCollapsed?: boolean; isSmall?: boolean }) => {
const field = isSmall ? 'icon' : 'logo'; const field = isSmall ? 'icon' : 'logo';
const logoColor = useColorModeValue('blue.600', 'white'); const logoColor = useColorModeValue('blue.600', 'white');
...@@ -38,12 +37,11 @@ const LogoFallback = ({ isCollapsed, isSmall, imageProps }: { isCollapsed?: bool ...@@ -38,12 +37,11 @@ const LogoFallback = ({ isCollapsed, isSmall, imageProps }: { isCollapsed?: bool
height="100%" height="100%"
color={ logoColor } color={ logoColor }
display={ display } display={ display }
{ ...imageProps }
/> />
); );
}; };
const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => { const NetworkLogo = ({ isCollapsed, onClick, className }: Props) => {
const logoSrc = useColorModeValue(config.UI.sidebar.logo.default, config.UI.sidebar.logo.dark || config.UI.sidebar.logo.default); const logoSrc = useColorModeValue(config.UI.sidebar.logo.default, config.UI.sidebar.logo.dark || config.UI.sidebar.logo.default);
const iconSrc = useColorModeValue(config.UI.sidebar.icon.default, config.UI.sidebar.icon.dark || config.UI.sidebar.icon.default); const iconSrc = useColorModeValue(config.UI.sidebar.icon.default, config.UI.sidebar.icon.dark || config.UI.sidebar.icon.default);
...@@ -53,6 +51,7 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => { ...@@ -53,6 +51,7 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => {
return ( return (
<Box <Box
className={ className }
as="a" as="a"
href={ route({ pathname: '/' }) } href={ route({ pathname: '/' }) }
width={{ base: '120px', lg: isCollapsed === false ? '120px' : '30px', xl: isCollapsed ? '30px' : '120px' }} width={{ base: '120px', lg: isCollapsed === false ? '120px' : '30px', xl: isCollapsed ? '30px' : '120px' }}
...@@ -69,10 +68,9 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => { ...@@ -69,10 +68,9 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => {
h="100%" h="100%"
src={ logoSrc } src={ logoSrc }
alt={ `${ config.chain.name } network logo` } alt={ `${ config.chain.name } network logo` }
fallback={ <LogoFallback isCollapsed={ isCollapsed } imageProps={ imageProps }/> } fallback={ <LogoFallback isCollapsed={ isCollapsed }/> }
display={{ base: 'block', lg: isCollapsed === false ? 'block' : 'none', xl: isCollapsed ? 'none' : 'block' }} display={{ base: 'block', lg: isCollapsed === false ? 'block' : 'none', xl: isCollapsed ? 'none' : 'block' }}
style={ logoStyle } style={ logoStyle }
{ ...imageProps }
/> />
{ /* small logo */ } { /* small logo */ }
<Image <Image
...@@ -80,13 +78,12 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => { ...@@ -80,13 +78,12 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => {
h="100%" h="100%"
src={ iconSrc } src={ iconSrc }
alt={ `${ config.chain.name } network logo` } alt={ `${ config.chain.name } network logo` }
fallback={ <LogoFallback isCollapsed={ isCollapsed } imageProps={ imageProps } isSmall/> } fallback={ <LogoFallback isCollapsed={ isCollapsed } isSmall/> }
display={{ base: 'none', lg: isCollapsed === false ? 'none' : 'block', xl: isCollapsed ? 'block' : 'none' }} display={{ base: 'none', lg: isCollapsed === false ? 'none' : 'block', xl: isCollapsed ? 'block' : 'none' }}
style={ iconStyle } style={ iconStyle }
{ ...imageProps }
/> />
</Box> </Box>
); );
}; };
export default React.memo(NetworkLogo); export default React.memo(chakra(NetworkLogo));
...@@ -45,10 +45,12 @@ const NetworkMenuContentMobile = ({ items, tabs }: Props) => { ...@@ -45,10 +45,12 @@ const NetworkMenuContentMobile = ({ items, tabs }: Props) => {
</> </>
) : ( ) : (
<> <>
<Select size="xs" borderRadius="base" value={ selectedTab } onChange={ handleSelectChange } focusBorderColor="none"> { tabs.length > 1 && (
{ tabs.map((tab) => <option key={ tab } value={ tab }>{ capitalize(tab) }</option>) } <Select size="xs" borderRadius="base" value={ selectedTab } onChange={ handleSelectChange } focusBorderColor="none" mb={ 6 }>
</Select> { tabs.map((tab) => <option key={ tab } value={ tab }>{ capitalize(tab) }</option>) }
<VStack as="ul" spacing={ 2 } alignItems="stretch" mt={ 6 }> </Select>
) }
<VStack as="ul" spacing={ 2 } alignItems="stretch">
{ items { items
.filter(({ group }) => group === selectedTab) .filter(({ group }) => group === selectedTab)
.map((network) => ( .map((network) => (
......
import { Box, Button, Text, VStack, useColorModeValue } from '@chakra-ui/react'; import { Box, Button, VStack, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { UserInfo } from 'types/api/account'; import type { UserInfo } from 'types/api/account';
...@@ -18,7 +18,6 @@ type Props = { ...@@ -18,7 +18,6 @@ type Props = {
const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => { const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => {
const { accountNavItems, profileItem } = useNavItems(); const { accountNavItems, profileItem } = useNavItems();
const primaryTextColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
const handleSingOutClick = React.useCallback(() => { const handleSingOutClick = React.useCallback(() => {
mixpanel.logEvent( mixpanel.logEvent(
...@@ -32,37 +31,28 @@ const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => { ...@@ -32,37 +31,28 @@ const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => {
return null; return null;
} }
const userName = data?.email || data?.nickname || data?.name;
return ( return (
<Box> <Box>
{ (data?.name || data?.nickname) && ( { userName && (
<Text <Box
fontSize="sm" fontSize="sm"
fontWeight={ 500 } fontWeight={ 500 }
color={ primaryTextColor }
{ ...getDefaultTransitionProps() }
>
Signed in as { data.name || data.nickname }
</Text>
) }
{ data?.email && (
<Text
fontSize="sm"
mb={ 1 } mb={ 1 }
fontWeight={ 500 }
color="gray.500"
{ ...getDefaultTransitionProps() }
> >
{ data.email } <span>Signed in as </span>
</Text> <chakra.span color="text_secondary">{ userName }</chakra.span>
</Box>
) } ) }
<NavLink item={ profileItem } isActive={ undefined } px="0px" isCollapsed={ false } onClick={ onNavLinkClick }/> <NavLink item={ profileItem } disableActiveState={ true } px="0px" isCollapsed={ false } onClick={ onNavLinkClick }/>
<Box as="nav" mt={ 2 } pt={ 2 } borderTopColor="divider" borderTopWidth="1px" { ...getDefaultTransitionProps() }> <Box as="nav" mt={ 2 } pt={ 2 } borderTopColor="divider" borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<VStack as="ul" spacing="0" alignItems="flex-start" overflow="hidden"> <VStack as="ul" spacing="0" alignItems="flex-start" overflow="hidden">
{ accountNavItems.map((item) => ( { accountNavItems.map((item) => (
<NavLink <NavLink
key={ item.text } key={ item.text }
item={ item } item={ item }
isActive={ undefined } disableActiveState={ true }
isCollapsed={ false } isCollapsed={ false }
px="0px" px="0px"
onClick={ onNavLinkClick } onClick={ onNavLinkClick }
......
...@@ -97,7 +97,7 @@ const ProfileMenuDesktop = ({ isHomePage, className, fallbackIconSize }: Props) ...@@ -97,7 +97,7 @@ const ProfileMenuDesktop = ({ isHomePage, className, fallbackIconSize }: Props)
</Box> </Box>
</Tooltip> </Tooltip>
{ hasMenu && ( { hasMenu && (
<PopoverContent w="212px"> <PopoverContent maxW="400px" minW="220px" w="min-content">
<PopoverBody padding="24px 16px 16px 16px"> <PopoverBody padding="24px 16px 16px 16px">
<ProfileMenuContent data={ data }/> <ProfileMenuContent data={ data }/>
</PopoverBody> </PopoverBody>
......
...@@ -66,7 +66,7 @@ const ProfileMenuMobile = () => { ...@@ -66,7 +66,7 @@ const ProfileMenuMobile = () => {
autoFocus={ false } autoFocus={ false }
> >
<DrawerOverlay/> <DrawerOverlay/>
<DrawerContent maxWidth="260px"> <DrawerContent maxWidth="300px">
<DrawerBody p={ 6 }> <DrawerBody p={ 6 }>
<ProfileMenuContent data={ data } onNavLinkClick={ onClose }/> <ProfileMenuContent data={ data } onNavLinkClick={ onClose }/>
</DrawerBody> </DrawerBody>
......
import { Box, Button, Text, Flex } from '@chakra-ui/react'; import { Box, Button, Text, Flex, IconButton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
...@@ -12,15 +12,24 @@ type Props = { ...@@ -12,15 +12,24 @@ type Props = {
address?: string; address?: string;
disconnect?: () => void; disconnect?: () => void;
isAutoConnectDisabled?: boolean; isAutoConnectDisabled?: boolean;
openWeb3Modal: () => void;
closeWalletMenu: () => void;
}; };
const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props) => { const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled, openWeb3Modal, closeWalletMenu }: Props) => {
const { themedBackgroundOrange } = useMenuButtonColors(); const { themedBackgroundOrange } = useMenuButtonColors();
const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const onAddressClick = React.useCallback(() => { const onAddressClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Address click' }); mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Address click' });
}, []); }, []);
const handleOpenWeb3Modal = React.useCallback(async() => {
setIsModalOpening(true);
await openWeb3Modal();
setTimeout(closeWalletMenu, 300);
}, [ openWeb3Modal, closeWalletMenu ]);
return ( return (
<Box> <Box>
{ isAutoConnectDisabled && ( { isAutoConnectDisabled && (
...@@ -60,16 +69,28 @@ const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props ...@@ -60,16 +69,28 @@ const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props
> >
Your wallet is used to interact with apps and contracts in the explorer. Your wallet is used to interact with apps and contracts in the explorer.
</Text> </Text>
<AddressEntity <Flex alignItems="center" mb={ 6 }>
address={{ hash: address }} <AddressEntity
noTooltip address={{ hash: address }}
truncation="dynamic" noTooltip
fontSize="sm" truncation="dynamic"
fontWeight={ 700 } fontSize="sm"
color="text" fontWeight={ 700 }
mb={ 6 } color="text"
onClick={ onAddressClick } onClick={ onAddressClick }
/> flex={ 1 }
/>
<IconButton
aria-label="open wallet"
icon={ <IconSvg name="gear_slim" boxSize={ 5 }/> }
variant="simple"
h="20px"
w="20px"
ml={ 1 }
onClick={ handleOpenWeb3Modal }
isLoading={ isModalOpening }
/>
</Flex>
<Button size="sm" width="full" variant="outline" onClick={ disconnect }> <Button size="sm" width="full" variant="outline" onClick={ disconnect }>
Disconnect Disconnect
</Button> </Button>
......
...@@ -20,7 +20,7 @@ type Props = { ...@@ -20,7 +20,7 @@ type Props = {
}; };
const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => { const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => {
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet({ source: 'Header' }); const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen, openModal } = useWallet({ source: 'Header' });
const { themedBackground, themedBackgroundOrange, themedBorderColor, themedColor } = useMenuButtonColors(); const { themedBackground, themedBackgroundOrange, themedBorderColor, themedColor } = useMenuButtonColors();
const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false); const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -82,7 +82,7 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => { ...@@ -82,7 +82,7 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => {
variant={ variant } variant={ variant }
colorScheme="blue" colorScheme="blue"
flexShrink={ 0 } flexShrink={ 0 }
isLoading={ isModalOpening || isModalOpen } isLoading={ (isModalOpening || isModalOpen) && !isWalletConnected }
loadingText="Connect wallet" loadingText="Connect wallet"
onClick={ isWalletConnected ? openPopover : connect } onClick={ isWalletConnected ? openPopover : connect }
fontSize="sm" fontSize="sm"
...@@ -102,7 +102,13 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => { ...@@ -102,7 +102,13 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => {
{ isWalletConnected && ( { isWalletConnected && (
<PopoverContent w="235px"> <PopoverContent w="235px">
<PopoverBody padding="24px 16px 16px 16px"> <PopoverBody padding="24px 16px 16px 16px">
<WalletMenuContent address={ address } disconnect={ disconnect } isAutoConnectDisabled={ isAutoConnectDisabled }/> <WalletMenuContent
address={ address }
disconnect={ disconnect }
isAutoConnectDisabled={ isAutoConnectDisabled }
openWeb3Modal={ openModal }
closeWalletMenu={ setIsPopoverOpen.off }
/>
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
) } ) }
......
...@@ -14,7 +14,7 @@ import WalletTooltip from './WalletTooltip'; ...@@ -14,7 +14,7 @@ import WalletTooltip from './WalletTooltip';
const WalletMenuMobile = () => { const WalletMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet({ source: 'Header' }); const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen, openModal } = useWallet({ source: 'Header' });
const { themedBackground, themedBackgroundOrange, themedBorderColor, themedColor } = useMenuButtonColors(); const { themedBackground, themedBackgroundOrange, themedBorderColor, themedColor } = useMenuButtonColors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { isAutoConnectDisabled } = useMarketplaceContext(); const { isAutoConnectDisabled } = useMarketplaceContext();
...@@ -48,7 +48,7 @@ const WalletMenuMobile = () => { ...@@ -48,7 +48,7 @@ const WalletMenuMobile = () => {
color={ themedColor } color={ themedColor }
borderColor={ !isWalletConnected ? themedBorderColor : undefined } borderColor={ !isWalletConnected ? themedBorderColor : undefined }
onClick={ isWalletConnected ? openPopover : connect } onClick={ isWalletConnected ? openPopover : connect }
isLoading={ isModalOpening || isModalOpen } isLoading={ (isModalOpening || isModalOpen) && !isWalletConnected }
/> />
</WalletTooltip> </WalletTooltip>
{ isWalletConnected && ( { isWalletConnected && (
...@@ -61,7 +61,13 @@ const WalletMenuMobile = () => { ...@@ -61,7 +61,13 @@ const WalletMenuMobile = () => {
<DrawerOverlay/> <DrawerOverlay/>
<DrawerContent maxWidth="260px"> <DrawerContent maxWidth="260px">
<DrawerBody p={ 6 }> <DrawerBody p={ 6 }>
<WalletMenuContent address={ address } disconnect={ disconnect } isAutoConnectDisabled={ isAutoConnectDisabled }/> <WalletMenuContent
address={ address }
disconnect={ disconnect }
isAutoConnectDisabled={ isAutoConnectDisabled }
openWeb3Modal={ openModal }
closeWalletMenu={ onClose }
/>
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
......
...@@ -45,6 +45,7 @@ export default function useWallet({ source }: Params) { ...@@ -45,6 +45,7 @@ export default function useWallet({ source }: Params) {
const isWalletConnected = isClientLoaded && !isDisconnected && address !== undefined; const isWalletConnected = isClientLoaded && !isDisconnected && address !== undefined;
return { return {
openModal: open,
isWalletConnected, isWalletConnected,
address: address || '', address: address || '',
connect: handleConnect, connect: handleConnect,
......
import { Box, Flex, Tooltip } from '@chakra-ui/react'; import { Box, Flex, Tooltip, useToken } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { Address } from 'types/api/address'; import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import type { EntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app'; import config from 'configs/app';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
...@@ -14,7 +16,9 @@ import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu' ...@@ -14,7 +16,9 @@ import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import sortEntityTags from 'ui/shared/EntityTags/sortEntityTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -24,9 +28,10 @@ import TokenVerifiedInfo from './TokenVerifiedInfo'; ...@@ -24,9 +28,10 @@ import TokenVerifiedInfo from './TokenVerifiedInfo';
interface Props { interface Props {
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>; tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
addressQuery: UseQueryResult<Address, ResourceError<unknown>>; addressQuery: UseQueryResult<Address, ResourceError<unknown>>;
hash: string;
} }
const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { const TokenPageTitle = ({ tokenQuery, addressQuery, hash }: Props) => {
const appProps = useAppContext(); const appProps = useAppContext();
const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : ''; const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : '';
...@@ -35,9 +40,12 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { ...@@ -35,9 +40,12 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled }, queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled },
}); });
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData || ( const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
config.features.verifiedTokens.isEnabled ? verifiedInfoQuery.isPending : false const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
);
const isLoading = tokenQuery.isPlaceholderData ||
addressQuery.isPlaceholderData ||
(config.features.verifiedTokens.isEnabled && verifiedInfoQuery.isPending);
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : ''; const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
...@@ -54,6 +62,37 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { ...@@ -54,6 +62,37 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
}; };
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
const bridgedTokenTagBgColor = useToken('colors', 'blue.500');
const bridgedTokenTagTextColor = useToken('colors', 'white');
const tags: Array<EntityTag> = React.useMemo(() => {
return [
tokenQuery.data ? { slug: tokenQuery.data?.type, name: tokenQuery.data?.type, tagType: 'custom' as const, ordinal: -20 } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{
slug: 'bridged',
name: 'Bridged',
tagType: 'custom' as const,
ordinal: -10,
meta: { bgColor: bridgedTokenTagBgColor, textColor: bridgedTokenTagTextColor },
} :
undefined,
...formatUserTags(addressQuery.data),
verifiedInfoQuery.data?.projectSector ?
{ slug: verifiedInfoQuery.data.projectSector, name: verifiedInfoQuery.data.projectSector, tagType: 'custom' as const, ordinal: -30 } :
undefined,
...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []),
].filter(Boolean).sort(sortEntityTags);
}, [
addressMetadataQuery.data?.addresses,
addressQuery.data,
bridgedTokenTagBgColor,
bridgedTokenTagTextColor,
tokenQuery.data,
verifiedInfoQuery.data?.projectSector,
hash,
]);
const contentAfter = ( const contentAfter = (
<> <>
{ verifiedInfoQuery.data?.tokenAddress && ( { verifiedInfoQuery.data?.tokenAddress && (
...@@ -64,19 +103,8 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { ...@@ -64,19 +103,8 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
</Tooltip> </Tooltip>
) } ) }
<EntityTags <EntityTags
data={ addressQuery.data } isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
isLoading={ isLoading } tags={ tags }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{ label: 'bridged', display_name: 'Bridged', colorScheme: 'blue', variant: 'solid' } :
undefined,
] }
tagsAfter={
verifiedInfoQuery.data?.projectSector ?
[ { label: verifiedInfoQuery.data.projectSector, display_name: verifiedInfoQuery.data.projectSector } ] :
undefined
}
flexGrow={ 1 } flexGrow={ 1 }
/> />
</> </>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as txMock from 'mocks/txs/tx'; import * as txMock from 'mocks/txs/tx';
import { txInterpretation } from 'mocks/txs/txInterpretation'; import { txInterpretation } from 'mocks/txs/txInterpretation';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import TxSubHeading from './TxSubHeading'; import TxSubHeading from './TxSubHeading';
import type { TxQuery } from './useTxQuery'; import type { TxQuery } from './useTxQuery';
const hash = '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193'; const hash = '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193';
const TX_INTERPRETATION_API_URL = buildApiUrl('tx_interpretation', { hash });
const txQuery = { const txQuery = {
data: txMock.base, data: txMock.base,
isPlaceholderData: false, isPlaceholderData: false,
isError: false, isError: false,
} as TxQuery; } as TxQuery;
test('no interpretation +@mobile', async({ mount }) => { test('no interpretation +@mobile', async({ render }) => {
const component = await mount( const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
const bsInterpretationTest = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.txInterpretation) as any,
});
bsInterpretationTest('with interpretation +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txInterpretation),
}));
const component = await mount(
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
bsInterpretationTest('with interpretation and view all link +@mobile', async({ mount, page }) => {
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ data: { summaries: [ ...txInterpretation.data.summaries, ...txInterpretation.data.summaries ] } }),
}));
const component = await mount(
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
bsInterpretationTest('no interpretation, has method called', async({ mount, page }) => { test.describe('blockscout provider', () => {
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({ test.beforeEach(async({ mockEnvs }) => {
status: 200, await mockEnvs(ENVS_MAP.txInterpretation);
body: JSON.stringify({ data: { summaries: [] } }), });
}));
test('with interpretation +@mobile +@dark-mode', async({ render, mockApiResponse }) => {
const component = await mount( await mockApiResponse('tx_interpretation', txInterpretation, { pathParams: { hash } });
<TestApp> const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/> await expect(component).toHaveScreenshot();
</TestApp>, });
);
test('with interpretation and view all link +@mobile', async({ render, mockApiResponse }) => {
await expect(component).toHaveScreenshot(); await mockApiResponse(
}); 'tx_interpretation',
{ data: { summaries: [ ...txInterpretation.data.summaries, ...txInterpretation.data.summaries ] } },
bsInterpretationTest('no interpretation', async({ mount, page }) => { { pathParams: { hash } },
const txPendingQuery = { );
data: txMock.pending, const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
isPlaceholderData: false, await expect(component).toHaveScreenshot();
isError: false, });
} as TxQuery;
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({ test('no interpretation, has method called', async({ render, mockApiResponse }) => {
status: 200, await mockApiResponse('tx_interpretation', { data: { summaries: [] } }, { pathParams: { hash } });
body: JSON.stringify({ data: { summaries: [] } }), const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
})); await expect(component).toHaveScreenshot();
});
const component = await mount(
<TestApp> test('no interpretation', async({ render, mockApiResponse }) => {
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txPendingQuery }/> const txPendingQuery = {
</TestApp>, data: txMock.pending,
); isPlaceholderData: false,
isError: false,
await expect(component).toHaveScreenshot(); } as TxQuery;
await mockApiResponse('tx_interpretation', { data: { summaries: [] } }, { pathParams: { hash } });
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txPendingQuery }/>);
await expect(component).toHaveScreenshot();
});
}); });
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import type { OptimisticL2WithdrawalStatus } from 'types/api/optimisticL2'; import type { OptimisticL2WithdrawalStatus } from 'types/api/optimisticL2';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import * as configs from 'playwright/utils/configs';
import TxDetailsWithdrawalStatus from './TxDetailsWithdrawalStatus'; import TxDetailsWithdrawalStatus from './TxDetailsWithdrawalStatus';
...@@ -16,20 +14,13 @@ const statuses: Array<OptimisticL2WithdrawalStatus> = [ ...@@ -16,20 +14,13 @@ const statuses: Array<OptimisticL2WithdrawalStatus> = [
'Relayed', 'Relayed',
]; ];
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
statuses.forEach((status) => { statuses.forEach((status) => {
test(`status="${ status }"`, async({ mount }) => { test(`status="${ status }"`, async({ render, mockEnvs }) => {
await mockEnvs(ENVS_MAP.optimisticRollup);
const component = await mount( const component = await render(
<TestApp> <Box p={ 2 }>
<Box p={ 2 }> <TxDetailsWithdrawalStatus status={ status } l1TxHash="0x7d93a59a228e97d084a635181c3053e324237d07566ec12287eae6da2bcf9456"/>
<TxDetailsWithdrawalStatus status={ status } l1TxHash="0x7d93a59a228e97d084a635181c3053e324237d07566ec12287eae6da2bcf9456"/> </Box>,
</Box>
</TestApp>,
); );
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as txMock from 'mocks/txs/tx'; import * as txMock from 'mocks/txs/tx';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import * as configs from 'playwright/utils/configs'; import * as configs from 'playwright/utils/configs';
import TxInfo from './TxInfo'; import TxInfo from './TxInfo';
const hooksConfig = { test('between addresses +@mobile +@dark-mode', async({ render, page }) => {
router: { const component = await render(<TxInfo data={ txMock.base } isLoading={ false }/>);
query: { hash: 1 },
},
};
test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.base } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click(); await page.getByText('View details').click();
...@@ -30,13 +18,8 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -30,13 +18,8 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
}); });
}); });
test('creating contact', async({ mount, page }) => { test('creating contact', async({ render, page }) => {
const component = await mount( const component = await render(<TxInfo data={ txMock.withContractCreation } isLoading={ false }/>);
<TestApp>
<TxInfo data={ txMock.withContractCreation } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
...@@ -44,13 +27,8 @@ test('creating contact', async({ mount, page }) => { ...@@ -44,13 +27,8 @@ test('creating contact', async({ mount, page }) => {
}); });
}); });
test('with token transfer +@mobile', async({ mount, page }) => { test('with token transfer +@mobile', async({ render, page }) => {
const component = await mount( const component = await render(<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>);
<TestApp>
<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
...@@ -58,13 +36,8 @@ test('with token transfer +@mobile', async({ mount, page }) => { ...@@ -58,13 +36,8 @@ test('with token transfer +@mobile', async({ mount, page }) => {
}); });
}); });
test('with decoded revert reason', async({ mount, page }) => { test('with decoded revert reason', async({ render, page }) => {
const component = await mount( const component = await render(<TxInfo data={ txMock.withDecodedRevertReason } isLoading={ false }/>);
<TestApp>
<TxInfo data={ txMock.withDecodedRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
...@@ -72,13 +45,8 @@ test('with decoded revert reason', async({ mount, page }) => { ...@@ -72,13 +45,8 @@ test('with decoded revert reason', async({ mount, page }) => {
}); });
}); });
test('with decoded raw reason', async({ mount, page }) => { test('with decoded raw reason', async({ render, page }) => {
const component = await mount( const component = await render(<TxInfo data={ txMock.withRawRevertReason } isLoading={ false }/>);
<TestApp>
<TxInfo data={ txMock.withRawRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
...@@ -86,13 +54,8 @@ test('with decoded raw reason', async({ mount, page }) => { ...@@ -86,13 +54,8 @@ test('with decoded raw reason', async({ mount, page }) => {
}); });
}); });
test('pending', async({ mount, page }) => { test('pending', async({ render, page }) => {
const component = await mount( const component = await render(<TxInfo data={ txMock.pending } isLoading={ false }/>);
<TestApp>
<TxInfo data={ txMock.pending } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click(); await page.getByText('View details').click();
...@@ -102,13 +65,8 @@ test('pending', async({ mount, page }) => { ...@@ -102,13 +65,8 @@ test('pending', async({ mount, page }) => {
}); });
}); });
test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => { test('with actions uniswap +@mobile +@dark-mode', async({ render, page }) => {
const component = await mount( const component = await render(<TxInfo data={ txMock.withActionsUniswap } isLoading={ false }/>);
<TestApp>
<TxInfo data={ txMock.withActionsUniswap } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
...@@ -116,13 +74,8 @@ test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -116,13 +74,8 @@ test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => {
}); });
}); });
test('with blob', async({ mount, page }) => { test('with blob', async({ render, page }) => {
const component = await mount( const component = await render(<TxInfo data={ txMock.withBlob } isLoading={ false }/>);
<TestApp>
<TxInfo data={ txMock.withBlob } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click(); await page.getByText('View details').click();
...@@ -132,39 +85,20 @@ test('with blob', async({ mount, page }) => { ...@@ -132,39 +85,20 @@ test('with blob', async({ mount, page }) => {
}); });
}); });
const l2Test = test.extend({ test('l2', async({ render, page, mockEnvs }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.optimisticRollup);
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, const component = await render(<TxInfo data={ txMock.l2tx } isLoading={ false }/>);
});
l2Test('l2', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor, maskColor: configs.maskColor,
}); });
}); });
const mainnetTest = test.extend({ test('without testnet warning', async({ render, page, mockEnvs }) => {
context: contextWithEnvs([ await mockEnvs([
{ name: 'NEXT_PUBLIC_IS_TESTNET', value: 'false' }, [ 'NEXT_PUBLIC_IS_TESTNET', 'false' ],
// eslint-disable-next-line @typescript-eslint/no-explicit-any ]);
]) as any, const component = await render(<TxInfo data={ txMock.l2tx } isLoading={ false }/>);
});
mainnetTest('without testnet warning', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
...@@ -172,18 +106,9 @@ mainnetTest('without testnet warning', async({ mount, page }) => { ...@@ -172,18 +106,9 @@ mainnetTest('without testnet warning', async({ mount, page }) => {
}); });
}); });
const stabilityTest = test.extend({ test('stability customization', async({ render, page, mockEnvs }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any await mockEnvs(ENVS_MAP.stabilityEnvs);
context: contextWithEnvs(configs.stabilityEnvs) as any, const component = await render(<TxInfo data={ txMock.stabilityTx } isLoading={ false }/>);
});
stabilityTest('stability customization', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.stabilityTx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ], mask: [ page.locator(configs.adsBannerSelector) ],
......
...@@ -160,7 +160,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -160,7 +160,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</Tag> </Tag>
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
{ rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && data.op_withdrawals && data.op_withdrawals.length > 0 && ( { rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && data.op_withdrawals && data.op_withdrawals.length > 0 &&
!config.UI.views.tx.hiddenFields?.L1_status && (
<DetailsInfoItem <DetailsInfoItem
title="Withdrawal status" title="Withdrawal status"
hint="Detailed status progress of the transaction" hint="Detailed status progress of the transaction"
...@@ -181,7 +182,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -181,7 +182,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</Flex> </Flex>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ data.zkevm_status && ( { data.zkevm_status && !config.UI.views.tx.hiddenFields?.L1_status && (
<DetailsInfoItem <DetailsInfoItem
title="Confirmation status" title="Confirmation status"
hint="Status of the transaction confirmation path to L1" hint="Status of the transaction confirmation path to L1"
...@@ -198,7 +199,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -198,7 +199,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
<TxRevertReason { ...data.revert_reason }/> <TxRevertReason { ...data.revert_reason }/>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ data.zksync && ( { data.zksync && !config.UI.views.tx.hiddenFields?.L1_status && (
<DetailsInfoItem <DetailsInfoItem
title="L1 status" title="L1 status"
hint="Status is the short interpretation of the batch lifecycle" hint="Status is the short interpretation of the batch lifecycle"
...@@ -229,7 +230,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -229,7 +230,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</> </>
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
{ data.zkevm_batch_number && ( { data.zkevm_batch_number && !config.UI.views.tx.hiddenFields?.batch && (
<DetailsInfoItem <DetailsInfoItem
title="Tx batch" title="Tx batch"
hint="Batch index for this transaction" hint="Batch index for this transaction"
...@@ -241,7 +242,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -241,7 +242,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
/> />
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ data.zksync && ( { data.zksync && !config.UI.views.tx.hiddenFields?.batch && (
<DetailsInfoItem <DetailsInfoItem
title="Batch" title="Batch"
hint="Batch number" hint="Batch number"
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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