diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000000..30ec4f8b6f --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,33 @@ +## 🚀 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 diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index ad040c80bd..e738ae3e63 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -50,11 +50,11 @@ frontend: NEXT_PUBLIC_APP_ENV: development NEXT_PUBLIC_APP_INSTANCE: review 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_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg - NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg - NEXT_PUBLIC_API_HOST: eth-sepolia.blockscout.com - NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/ + 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/sepolia.svg + NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/sepolia.png + NEXT_PUBLIC_API_HOST: eth-sepolia.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_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 diff --git a/docs/ENVS.md b/docs/ENVS.md index 6331935af1..1972e42cdf 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -200,6 +200,8 @@ Settings for meta tags, OG tags and SEO | `total_reward` | Total block reward | | `nonce` | Block nonce | | `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 | `tx_fee` | Total transaction fee | | `gas_fees` | Gas fees breakdown | | `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 | Id | Description | diff --git a/icons/empty_search_result.svg b/icons/empty_search_result.svg index a60b3b1e70..f4d62eff0e 100644 --- a/icons/empty_search_result.svg +++ b/icons/empty_search_result.svg @@ -1,11 +1,16 @@ - - - + + + + + + - - - + + + + + diff --git a/lib/address/parseMetaPayload.ts b/lib/address/parseMetaPayload.ts index 80fc7fd8d1..ad5e8d401a 100644 --- a/lib/address/parseMetaPayload.ts +++ b/lib/address/parseMetaPayload.ts @@ -1,6 +1,8 @@ import type { AddressMetadataTag } from 'types/api/addressMetadata'; import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata'; +type MetaParsed = NonNullable; + export default function parseMetaPayload(meta: AddressMetadataTag['meta']): AddressMetadataTagFormatted['meta'] { try { const parsedMeta = JSON.parse(meta || ''); @@ -11,16 +13,20 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr const result: AddressMetadataTagFormatted['meta'] = {}; - if ('textColor' in parsedMeta && typeof parsedMeta.textColor === 'string') { - result.textColor = parsedMeta.textColor; - } - - if ('bgColor' in parsedMeta && typeof parsedMeta.bgColor === 'string') { - result.bgColor = parsedMeta.bgColor; - } + const stringFields: Array = [ + 'textColor', + 'bgColor', + 'tagUrl', + 'tooltipIcon', + 'tooltipTitle', + 'tooltipDescription', + 'tooltipUrl', + ]; - if ('actionURL' in parsedMeta && typeof parsedMeta.actionURL === 'string') { - result.actionURL = parsedMeta.actionURL; + for (const stringField of stringFields) { + if (stringField in parsedMeta && typeof parsedMeta[stringField as keyof typeof parsedMeta] === 'string') { + result[stringField] = parsedMeta[stringField as keyof typeof parsedMeta]; + } } return result; diff --git a/lib/growthbook/init.ts b/lib/growthbook/init.ts index 10674a4fcc..d98b2b94b7 100644 --- a/lib/growthbook/init.ts +++ b/lib/growthbook/init.ts @@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts'; export interface GrowthBookFeatures { test_value: string; - security_score_exp: boolean; } export const growthBook = (() => { diff --git a/lib/makePrettyLink.ts b/lib/makePrettyLink.ts new file mode 100644 index 0000000000..9e05a2d660 --- /dev/null +++ b/lib/makePrettyLink.ts @@ -0,0 +1,9 @@ +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) {} +} diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index 7abb8b5ee0..b901d7cc5e 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -105,6 +105,10 @@ Type extends EventTypes.PAGE_WIDGET ? ( } | { 'Type': 'Security score'; 'Source': 'Analyzed contracts popup'; + } | { + 'Type': 'Address tag'; + 'Info': string; + 'URL': string; } ) : Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { diff --git a/lib/socket/types.ts b/lib/socket/types.ts index b2d8d7301a..d0f7564e56 100644 --- a/lib/socket/types.ts +++ b/lib/socket/types.ts @@ -28,6 +28,7 @@ SocketMessage.AddressTxs | SocketMessage.AddressTxsPending | SocketMessage.AddressTokenTransfer | SocketMessage.AddressChangedBytecode | +SocketMessage.AddressFetchedBytecode | SocketMessage.SmartContractWasVerified | SocketMessage.TokenTransfers | SocketMessage.TokenTotalSupply | @@ -64,6 +65,7 @@ export namespace SocketMessage { export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transactions: Array }>; export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfers: Array }>; export type AddressChangedBytecode = SocketMessageParamsGeneric<'changed_bytecode', Record>; + export type AddressFetchedBytecode = SocketMessageParamsGeneric<'fetched_bytecode', { fetched_bytecode: string }>; export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record>; export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>; export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>; diff --git a/lib/web3/useAccount.ts b/lib/web3/useAccount.ts new file mode 100644 index 0000000000..f3dfcd48c8 --- /dev/null +++ b/lib/web3/useAccount.ts @@ -0,0 +1,23 @@ +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; diff --git a/mocks/address/address.ts b/mocks/address/address.ts index 85eb1c5032..8ed50dca22 100644 --- a/mocks/address/address.ts +++ b/mocks/address/address.ts @@ -30,6 +30,24 @@ export const withEns: AddressParam = { 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 = { hash: hash, implementation_name: null, diff --git a/mocks/address/tabCounters.ts b/mocks/address/tabCounters.ts new file mode 100644 index 0000000000..3853ffab4d --- /dev/null +++ b/mocks/address/tabCounters.ts @@ -0,0 +1,11 @@ +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, +}; diff --git a/mocks/contract/methods.ts b/mocks/contract/methods.ts index 6c1bdf367e..61ee20d666 100644 --- a/mocks/contract/methods.ts +++ b/mocks/contract/methods.ts @@ -1,6 +1,6 @@ import type { - SmartContractQueryMethodReadError, - SmartContractQueryMethodReadSuccess, + SmartContractQueryMethodError, + SmartContractQueryMethodSuccess, SmartContractReadMethod, SmartContractWriteMethod, } from 'types/api/contract'; @@ -94,7 +94,7 @@ export const read: Array = [ }, ]; -export const readResultSuccess: SmartContractQueryMethodReadSuccess = { +export const readResultSuccess: SmartContractQueryMethodSuccess = { is_error: false, result: { names: [ 'amount' ], @@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = { }, }; -export const readResultError: SmartContractQueryMethodReadError = { +export const readResultError: SmartContractQueryMethodError = { is_error: true, result: { message: 'Some shit happened', diff --git a/mocks/l2withdrawals/withdrawals.ts b/mocks/l2withdrawals/withdrawals.ts index 0e0d69a22f..8882f8515d 100644 --- a/mocks/l2withdrawals/withdrawals.ts +++ b/mocks/l2withdrawals/withdrawals.ts @@ -1,4 +1,6 @@ -export const data = { +import type { OptimisticL2WithdrawalsResponse } from 'types/api/optimisticL2'; + +export const data: OptimisticL2WithdrawalsResponse = { items: [ { challenge_period_end: null, @@ -11,12 +13,12 @@ export const data = { private_tags: [], public_tags: [], watchlist_names: [], + ens_domain_name: null, }, l1_tx_hash: '0x1a235bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136dca684', l2_timestamp: '2022-02-15T12:50:02.000000Z', l2_tx_hash: '0x918cd8c5c24c17e06cd02b0379510c4ad56324bf153578fb9caaaa2fe4e7dc35', msg_nonce: 396, - msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620172', msg_nonce_version: 1, status: 'Ready to prove', }, @@ -27,7 +29,6 @@ export const data = { l2_timestamp: null, l2_tx_hash: '0x2f117bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136def593', msg_nonce: 391, - msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620167', msg_nonce_version: 1, status: 'Ready to prove', }, @@ -38,7 +39,6 @@ export const data = { l2_timestamp: null, l2_tx_hash: '0xe14b1f46838176702244a5343629bcecf728ca2d9881d47b4ce46e00c387d7e3', msg_nonce: 390, - msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620166', msg_nonce_version: 1, status: 'Ready for relay', }, diff --git a/mocks/metadata/address.ts b/mocks/metadata/address.ts index f629272ed4..4e7849bb50 100644 --- a/mocks/metadata/address.ts +++ b/mocks/metadata/address.ts @@ -1,36 +1,63 @@ -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 nameTag1: AddressMetadataTag = { - slug: 'ethermineru', - name: 'Ethermine.ru', +export const nameTag: AddressMetadataTagApi = { + slug: 'quack-quack', + name: 'Quack quack', tagType: 'name', - ordinal: 0, + ordinal: 99, meta: null, }; -export const genericTag1: AddressMetadataTag = { - slug: 'ethermine.ru', - name: 'Ethermine.ru', +export const customNameTag: AddressMetadataTagApi = { + slug: 'unicorn-uproar', + 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', - ordinal: 0, - meta: null, + ordinal: 55, + 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', name: 'Aerodrome', tagType: 'protocol', ordinal: 0, meta: null, }; - -export const baseInfo: AddressMetadataInfo = { - addresses: { - [hash]: { - tags: [ nameTag1, genericTag1, protocolTag1 ], - reputation: null, - }, - }, -}; diff --git a/mocks/withdrawals/withdrawals.ts b/mocks/withdrawals/withdrawals.ts index 37d1da51e1..d58d901390 100644 --- a/mocks/withdrawals/withdrawals.ts +++ b/mocks/withdrawals/withdrawals.ts @@ -1,4 +1,7 @@ -export const data = { +import type { AddressParam } from 'types/api/addressParams'; +import type { WithdrawalsResponse } from 'types/api/withdrawals'; + +export const data: WithdrawalsResponse = { items: [ { amount: '192175000000000', @@ -10,7 +13,7 @@ export const data = { is_contract: false, is_verified: null, name: null, - }, + } as AddressParam, timestamp: '2022-06-07T18:12:24.000000Z', validator_index: 49622, }, @@ -24,7 +27,7 @@ export const data = { is_contract: false, is_verified: null, name: null, - }, + } as AddressParam, timestamp: '2022-05-07T18:12:24.000000Z', validator_index: 49621, }, @@ -38,7 +41,7 @@ export const data = { is_contract: false, is_verified: null, name: null, - }, + } as AddressParam, timestamp: '2022-04-07T18:12:24.000000Z', validator_index: 49620, }, diff --git a/next.config.js b/next.config.js index 8789a1a641..79d1b38dca 100644 --- a/next.config.js +++ b/next.config.js @@ -10,6 +10,7 @@ const headers = require('./nextjs/headers'); const redirects = require('./nextjs/redirects'); const rewrites = require('./nextjs/rewrites'); +/** @type {import('next').NextConfig} */ const moduleExports = { transpilePackages: [ 'react-syntax-highlighter', @@ -46,6 +47,14 @@ const moduleExports = { productionBrowserSourceMaps: true, experimental: { instrumentationHook: true, + turbo: { + rules: { + '*.svg': { + loaders: [ '@svgr/webpack' ], + as: '*.js', + }, + }, + }, }, }; diff --git a/package.json b/package.json index 3c85287b18..fde1113c2e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@metamask/post-message-stream": "^7.0.0", "@metamask/providers": "^10.2.1", "@monaco-editor/react": "^4.4.6", - "@next/bundle-analyzer": "^14.0.1", + "@next/bundle-analyzer": "14.2.3", "@opentelemetry/auto-instrumentations-node": "^0.39.4", "@opentelemetry/exporter-metrics-otlp-proto": "^0.45.1", "@opentelemetry/exporter-trace-otlp-http": "^0.45.0", @@ -81,7 +81,7 @@ "magic-bytes.js": "1.8.0", "mixpanel-browser": "^2.47.0", "monaco-editor": "^0.34.1", - "next": "13.5.4", + "next": "14.2.3", "nextjs-routes": "^1.0.8", "node-fetch": "^3.2.9", "papaparse": "^5.3.2", diff --git a/playwright/TestApp.tsx b/playwright/TestApp.tsx index d8ef52ca2c..ca65a6e0c6 100644 --- a/playwright/TestApp.tsx +++ b/playwright/TestApp.tsx @@ -18,6 +18,7 @@ import theme from 'theme'; export type Props = { children: React.ReactNode; withSocket?: boolean; + withWalletClient?: boolean; appContext?: { pageProps: PageProps; }; @@ -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 ( + + { children } + + ); + } + + // 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({ defaultOptions: { queries: { @@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props - + { children } - + diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts index 69353ead37..f2e6fe7920 100644 --- a/playwright/fixtures/mockEnvs.ts +++ b/playwright/fixtures/mockEnvs.ts @@ -16,6 +16,11 @@ const fixture: TestFixture = async({ page }, us export default fixture; export const ENVS_MAP: Record> = { + 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: [ [ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ], [ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ], @@ -24,6 +29,10 @@ export const ENVS_MAP: Record> = { [ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ], [ '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: [ [ '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"}]' ], @@ -37,4 +46,17 @@ export const ENVS_MAP: Record> = { blockHiddenFields: [ [ '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', '' ], + ], }; diff --git a/playwright/fixtures/socketServer.ts b/playwright/fixtures/socketServer.ts index f630cdd012..f6d730ce42 100644 --- a/playwright/fixtures/socketServer.ts +++ b/playwright/fixtures/socketServer.ts @@ -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: 'total_supply', payload: { total_supply: number}): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_bytecode', payload: Record): 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): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array }): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void { diff --git a/playwright/utils/configs.ts b/playwright/utils/configs.ts index 1865f53a25..ec83566ac7 100644 --- a/playwright/utils/configs.ts +++ b/playwright/utils/configs.ts @@ -10,45 +10,3 @@ export const viewport = { export const maskColor = '#4299E1'; // blue.400 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"]' }, -]; diff --git a/tools/scripts/dev.preset.sh b/tools/scripts/dev.preset.sh index c8566819e0..c3384ca41f 100755 --- a/tools/scripts/dev.preset.sh +++ b/tools/scripts/dev.preset.sh @@ -28,5 +28,5 @@ dotenv \ -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ -e $config_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 \ No newline at end of file diff --git a/types/api/addressMetadata.ts b/types/api/addressMetadata.ts index e6f8c0dac4..18579bdead 100644 --- a/types/api/addressMetadata.ts +++ b/types/api/addressMetadata.ts @@ -7,6 +7,7 @@ export interface AddressMetadataInfo { export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol'; +// Response model from Metadata microservice API export interface AddressMetadataTag { slug: string; name: string; @@ -14,3 +15,16 @@ export interface AddressMetadataTag { ordinal: number; meta: string | null; } + +// Response model from Blockscout API with parsed meta field +export interface AddressMetadataTagApi extends Omit { + meta: { + textColor?: string; + bgColor?: string; + tagUrl?: string; + tooltipIcon?: string; + tooltipTitle?: string; + tooltipDescription?: string; + tooltipUrl?: string; + } | null; +} diff --git a/types/api/addressParams.ts b/types/api/addressParams.ts index fe8cb3d90c..8432062c4f 100644 --- a/types/api/addressParams.ts +++ b/types/api/addressParams.ts @@ -1,3 +1,5 @@ +import type { AddressMetadataTagApi } from './addressMetadata'; + export interface AddressTag { label: string; display_name: string; @@ -22,6 +24,10 @@ export type AddressParamBasic = { is_contract: boolean; is_verified: boolean | null; ens_domain_name: string | null; + metadata?: { + reputation: number | null; + tags: Array; + } | null; } export type AddressParam = UserTags & AddressParamBasic; diff --git a/types/api/contract.ts b/types/api/contract.ts index 22893140d5..50f4098d27 100644 --- a/types/api/contract.ts +++ b/types/api/contract.ts @@ -1,4 +1,4 @@ -import type { Abi, AbiType } from 'abitype'; +import type { Abi, AbiType, AbiFallback, AbiFunction, AbiReceive } from 'abitype'; export type SmartContractMethodArgType = AbiType; export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable'; @@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary { name: string; } -export interface SmartContractMethodBase { - inputs: Array; - outputs?: Array; - constant: boolean; - name: string; - stateMutability: SmartContractMethodStateMutability; - type: 'function'; - payable: boolean; - error?: string; +export type SmartContractMethodOutputValue = string | boolean | object; +export type SmartContractMethodOutput = AbiFunction['outputs'][number] & { value?: SmartContractMethodOutputValue }; +export type SmartContractMethodBase = Omit & { method_id: string; -} - + outputs: Array; + constant?: boolean; + error?: string; +}; export type SmartContractReadMethod = SmartContractMethodBase; - -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 SmartContractWriteMethod = SmartContractMethodBase | AbiFallback | AbiReceive; export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod; -export interface SmartContractMethodInput { - internalType?: string; // there could be any string, e.g "enum MyEnum" - name: string; - type: SmartContractMethodArgType; - components?: Array; - fieldType?: 'native_coin'; -} - -export interface SmartContractMethodOutput extends SmartContractMethodInput { - value?: string | boolean | object; -} - -export interface SmartContractQueryMethodReadSuccess { +export interface SmartContractQueryMethodSuccess { is_error: false; result: { names: Array ]>; @@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess { }; } -export interface SmartContractQueryMethodReadError { +export interface SmartContractQueryMethodError { is_error: true; result: { code: number; @@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError { }; } -export type SmartContractQueryMethodRead = SmartContractQueryMethodReadSuccess | SmartContractQueryMethodReadError; +export type SmartContractQueryMethod = SmartContractQueryMethodSuccess | SmartContractQueryMethodError; // VERIFICATION diff --git a/types/client/addressMetadata.ts b/types/client/addressMetadata.ts index fd6b36dc02..8281e1cf1b 100644 --- a/types/client/addressMetadata.ts +++ b/types/client/addressMetadata.ts @@ -1,4 +1,4 @@ -import type { AddressMetadataTagType } from 'types/api/addressMetadata'; +import type { AddressMetadataTagApi } from 'types/api/addressMetadata'; export interface AddressMetadataInfoFormatted { addresses: Record; } -export interface AddressMetadataTagFormatted { - slug: string; - name: string; - tagType: AddressMetadataTagType; - ordinal: number; - meta: { - textColor?: string; - bgColor?: string; - actionURL?: string; - } | null; -} +export type AddressMetadataTagFormatted = AddressMetadataTagApi; diff --git a/types/views/block.ts b/types/views/block.ts index e924d6189b..838dc523b7 100644 --- a/types/views/block.ts +++ b/types/views/block.ts @@ -5,6 +5,8 @@ export const BLOCK_FIELDS_IDS = [ 'total_reward', 'nonce', 'miner', + 'L1_status', + 'batch', ] as const; export type BlockFieldId = ArrayElement; diff --git a/types/views/tx.ts b/types/views/tx.ts index 21800d80e0..d3a30adcc5 100644 --- a/types/views/tx.ts +++ b/types/views/tx.ts @@ -7,6 +7,8 @@ export const TX_FIELDS_IDS = [ 'tx_fee', 'gas_fees', 'burnt_fees', + 'L1_status', + 'batch', ] as const; export type TxFieldsId = ArrayElement; diff --git a/ui/address/AddressContract.pw.tsx b/ui/address/AddressContract.pw.tsx new file mode 100644 index 0000000000..603df54668 --- /dev/null +++ b/ui/address/AddressContract.pw.tsx @@ -0,0 +1,94 @@ +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(, { 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(, { 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(, { 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(, { 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(); + }); +}); diff --git a/ui/address/AddressContract.pwstory.tsx b/ui/address/AddressContract.pwstory.tsx new file mode 100644 index 0000000000..c558dd9ca2 --- /dev/null +++ b/ui/address/AddressContract.pwstory.tsx @@ -0,0 +1,18 @@ +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 ; +}; + +export default AddressContractPwStory; diff --git a/ui/address/AddressContract.tsx b/ui/address/AddressContract.tsx index 5d8a6dd8d0..a349532a7a 100644 --- a/ui/address/AddressContract.tsx +++ b/ui/address/AddressContract.tsx @@ -3,7 +3,6 @@ import React from 'react'; import type { RoutedSubTab } from 'ui/shared/Tabs/types'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; -import Web3ModalProvider from 'ui/shared/Web3ModalProvider'; interface Props { tabs: Array; @@ -16,21 +15,12 @@ const TAB_LIST_PROPS = { }; const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => { - const fallback = React.useCallback(() => { - const noProviderTabs = tabs.filter(({ id }) => id === 'contract_code' || id.startsWith('read_')); - return ( - - ); - }, [ isLoading, tabs ]); - if (!shouldRender) { return null; } return ( - - - + ); }; diff --git a/ui/address/contract/ContractMethodsAccordion.tsx b/ui/address/contract/ABI/ContractAbi.tsx similarity index 58% rename from ui/address/contract/ContractMethodsAccordion.tsx rename to ui/address/contract/ABI/ContractAbi.tsx index b1e4c4bf6b..6b71c46c2d 100644 --- a/ui/address/contract/ContractMethodsAccordion.tsx +++ b/ui/address/contract/ABI/ContractAbi.tsx @@ -1,40 +1,27 @@ import { Accordion, Box, Flex, Link } from '@chakra-ui/react'; import _range from 'lodash/range'; 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 { - data: Array; - addressHash?: string; - renderItemContent: (item: T, index: number, id: number) => React.ReactNode; +interface Props { + data: TContractAbi; + addressHash: string; tab: string; + methodType: MethodType; } -const ContractMethodsAccordion = ({ data, addressHash, renderItemContent, tab }: Props) => { +const ContractAbi = ({ data, addressHash, tab, methodType }: Props) => { const [ expandedSections, setExpandedSections ] = React.useState>(data.length === 1 ? [ 0 ] : []); const [ id, setId ] = React.useState(0); - React.useEffect(() => { - const hash = window.location.hash.replace('#', ''); + useScrollToMethod(data, setExpandedSections); - if (!hash) { - 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 handleFormSubmit = useFormSubmit({ addressHash, tab }); const handleAccordionStateChange = React.useCallback((newValue: Array) => { setExpandedSections(newValue); @@ -73,14 +60,15 @@ const ContractMethodsAccordion = ({ data, address { data.map((item, index) => ( - React.ReactNode } tab={ tab } + onSubmit={ handleFormSubmit } + methodType={ methodType } /> )) } @@ -88,4 +76,4 @@ const ContractMethodsAccordion = ({ data, address ); }; -export default React.memo(ContractMethodsAccordion) as typeof ContractMethodsAccordion; +export default React.memo(ContractAbi); diff --git a/ui/address/contract/ContractMethodsAccordionItem.tsx b/ui/address/contract/ABI/ContractAbiItem.tsx similarity index 64% rename from ui/address/contract/ContractMethodsAccordionItem.tsx rename to ui/address/contract/ABI/ContractAbiItem.tsx index b30c0d998b..9aa3fc6889 100644 --- a/ui/address/contract/ContractMethodsAccordionItem.tsx +++ b/ui/address/contract/ABI/ContractAbiItem.tsx @@ -1,25 +1,32 @@ -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 { 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 config from 'configs/app'; +import Tag from 'ui/shared/chakra/Tag'; +import CopyToClipboard from 'ui/shared/CopyToClipboard'; import Hint from 'ui/shared/Hint'; import IconSvg from 'ui/shared/IconSvg'; -interface Props { - data: T; +import ContractAbiItemConstant from './ContractAbiItemConstant'; +import ContractMethodForm from './form/ContractMethodForm'; +import { getElementName } from './useScrollToMethod'; + +interface Props { + data: TContractAbiItem; index: number; id: number; - addressHash?: string; - renderContent: (item: T, index: number, id: number) => React.ReactNode; + addressHash: string; tab: string; + onSubmit: FormSubmitHandler; + methodType: MethodType; } -const ContractMethodsAccordionItem = ({ data, index, id, addressHash, renderContent, tab }: Props) => { +const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodType }: Props) => { const url = React.useMemo(() => { if (!('method_id' in data)) { return ''; @@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = ({ data, ind onCopy(); }, [ onCopy ]); + const handleCopyMethodIdClick = React.useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + + const content = (() => { + if ('error' in data && data.error) { + return { data.error }; + } + + const hasConstantOutputs = 'outputs' in data && data.outputs.some(({ value }) => value !== undefined && value !== null); + + if (hasConstantOutputs) { + return ( + + { data.outputs.map((output, index) => ) } + + ); + } + + return ( + + ); + })(); + return ( { ({ isExpanded }) => ( <> - + { 'method_id' in data && ( @@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = ({ data, ind the contract cannot receive Ether through regular transactions and throws an exception.` }/> ) } + { 'method_id' in data && ( + <> + { data.method_id } + + + ) } - { renderContent(data, index, id) } + { content } ) } @@ -97,4 +139,4 @@ const ContractMethodsAccordionItem = ({ data, ind ); }; -export default React.memo(ContractMethodsAccordionItem); +export default React.memo(ContractAbiItem); diff --git a/ui/address/contract/ContractMethodConstant.tsx b/ui/address/contract/ABI/ContractAbiItemConstant.tsx similarity index 83% rename from ui/address/contract/ContractMethodConstant.tsx rename to ui/address/contract/ABI/ContractAbiItemConstant.tsx index 016668e89b..1e2c3f213c 100644 --- a/ui/address/contract/ContractMethodConstant.tsx +++ b/ui/address/contract/ABI/ContractAbiItemConstant.tsx @@ -4,12 +4,14 @@ import type { ChangeEvent } from 'react'; import React from 'react'; import { getAddress } from 'viem'; -import type { SmartContractMethodOutput } from 'types/api/contract'; +import type { ContractAbiItemOutput } from './types'; import { WEI } from 'lib/consts'; import { currencyUnits } from 'lib/units'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import { matchInt } from './form/utils'; + function castValueToString(value: number | string | boolean | object | bigint | undefined): string { switch (typeof value) { case 'string': @@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint | } interface Props { - data: SmartContractMethodOutput; + data: ContractAbiItemOutput; } -const ContractMethodStatic = ({ data }: Props) => { +const ContractAbiItemConstant = ({ data }: Props) => { const [ value, setValue ] = React.useState(castValueToString(data.value)); const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase()); + const intMatch = matchInt(data.type); + const handleCheckboxChange = React.useCallback((event: ChangeEvent) => { const initialValue = castValueToString(data.value); @@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => { return ( { content } - { (data.type.includes('int256') || data.type.includes('int128')) && { label } } + { Number(intMatch?.power) >= 128 && { label } } ); }; -export default ContractMethodStatic; +export default ContractAbiItemConstant; diff --git a/ui/address/contract/methodForm/ContractMethodArrayButton.tsx b/ui/address/contract/ABI/form/ContractMethodArrayButton.tsx similarity index 100% rename from ui/address/contract/methodForm/ContractMethodArrayButton.tsx rename to ui/address/contract/ABI/form/ContractMethodArrayButton.tsx diff --git a/ui/address/contract/methodForm/ContractMethodFieldAccordion.tsx b/ui/address/contract/ABI/form/ContractMethodFieldAccordion.tsx similarity index 100% rename from ui/address/contract/methodForm/ContractMethodFieldAccordion.tsx rename to ui/address/contract/ABI/form/ContractMethodFieldAccordion.tsx diff --git a/ui/address/contract/methodForm/ContractMethodFieldInput.tsx b/ui/address/contract/ABI/form/ContractMethodFieldInput.tsx similarity index 97% rename from ui/address/contract/methodForm/ContractMethodFieldInput.tsx rename to ui/address/contract/ABI/form/ContractMethodFieldInput.tsx index 542506c953..cbe244b210 100644 --- a/ui/address/contract/methodForm/ContractMethodFieldInput.tsx +++ b/ui/address/contract/ABI/form/ContractMethodFieldInput.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { NumericFormat } from 'react-number-format'; -import type { SmartContractMethodInput } from 'types/api/contract'; +import type { ContractAbiItemInput } from '../types'; import ClearButton from 'ui/shared/ClearButton'; @@ -14,7 +14,7 @@ import useValidateField from './useValidateField'; import { matchInt } from './utils'; interface Props { - data: SmartContractMethodInput; + data: ContractAbiItemInput; hideLabel?: boolean; path: string; className?: string; diff --git a/ui/address/contract/methodForm/ContractMethodFieldInputArray.tsx b/ui/address/contract/ABI/form/ContractMethodFieldInputArray.tsx similarity index 98% rename from ui/address/contract/methodForm/ContractMethodFieldInputArray.tsx rename to ui/address/contract/ABI/form/ContractMethodFieldInputArray.tsx index a2a18f185c..add681e90b 100644 --- a/ui/address/contract/methodForm/ContractMethodFieldInputArray.tsx +++ b/ui/address/contract/ABI/form/ContractMethodFieldInputArray.tsx @@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react'; import React from 'react'; import { useFormContext } from 'react-hook-form'; -import type { SmartContractMethodInput } from 'types/api/contract'; +import type { ContractAbiItemInput } from '../types'; import ContractMethodArrayButton from './ContractMethodArrayButton'; import type { Props as AccordionProps } from './ContractMethodFieldAccordion'; @@ -13,7 +13,7 @@ import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import { getFieldLabel, matchArray, transformDataForArrayItem } from './utils'; interface Props extends Pick { - data: SmartContractMethodInput; + data: ContractAbiItemInput; level: number; basePath: string; isDisabled: boolean; diff --git a/ui/address/contract/methodForm/ContractMethodFieldInputTuple.tsx b/ui/address/contract/ABI/form/ContractMethodFieldInputTuple.tsx similarity index 91% rename from ui/address/contract/methodForm/ContractMethodFieldInputTuple.tsx rename to ui/address/contract/ABI/form/ContractMethodFieldInputTuple.tsx index b621459000..8c0426e473 100644 --- a/ui/address/contract/methodForm/ContractMethodFieldInputTuple.tsx +++ b/ui/address/contract/ABI/form/ContractMethodFieldInputTuple.tsx @@ -1,7 +1,7 @@ import React from 'react'; 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 ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; @@ -10,7 +10,7 @@ import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; import { getFieldLabel, matchArray } from './utils'; interface Props extends Pick { - data: SmartContractMethodInput; + data: ContractAbiItemInput; basePath: string; level: number; isDisabled: boolean; @@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a const fieldsWithErrors = Object.keys(errors); const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath)); + if (!('components' in data)) { + return null; + } + return ( { data.components?.map((component, index) => { - if (component.components && component.type === 'tuple') { + if ('components' in component && component.type === 'tuple') { return ( Promise.resolve({ hash: '0x0000' as `0x${ string }` }); -const resultComponent = () => null; +const onSubmit = () => Promise.resolve({ source: 'wallet_client' as const, result: { hash: '0x0000' as `0x${ string }` } }); -const data: SmartContractWriteMethod = { +const data: ContractAbiItem = { inputs: [ // TUPLE { @@ -102,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => { const component = await mount( - + , diff --git a/ui/address/contract/ABI/form/ContractMethodForm.tsx b/ui/address/contract/ABI/form/ContractMethodForm.tsx new file mode 100644 index 0000000000..cab71b2320 --- /dev/null +++ b/ui/address/contract/ABI/form/ContractMethodForm.tsx @@ -0,0 +1,208 @@ +import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react'; +import _mapValues from 'lodash/mapValues'; +import React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm, FormProvider } from 'react-hook-form'; +import type { AbiFunction } from 'viem'; + +import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, MethodType, ContractAbiItem } from '../types'; + +import config from 'configs/app'; +import * as mixpanel from 'lib/mixpanel/index'; + +import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; +import ContractMethodFieldInput from './ContractMethodFieldInput'; +import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; +import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; +import ContractMethodOutputs from './ContractMethodOutputs'; +import ContractMethodResult from './ContractMethodResult'; +import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils'; +import type { ContractMethodFormFields } from './utils'; + +interface Props { + data: ContractAbiItem; + onSubmit: FormSubmitHandler; + methodType: MethodType; +} + +const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => { + + const [ result, setResult ] = React.useState(); + const [ isLoading, setLoading ] = React.useState(false); + const [ callStrategy, setCallStrategy ] = React.useState(); + const callStrategyRef = React.useRef(callStrategy); + + const formApi = useForm({ + mode: 'all', + 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 = React.useCallback(async(formData) => { + // The API used for reading from contracts expects all values to be strings. + const formattedData = callStrategyRef.current === 'api' ? + _mapValues(formData, (value) => value !== undefined ? String(value) : undefined) : + formData; + const args = transformFormDataToMethodArgs(formattedData); + + setResult(undefined); + setLoading(true); + + onSubmit(data, args, callStrategyRef.current) + .then((result) => { + setResult(result); + }) + .catch((error) => { + setResult({ + source: callStrategyRef.current ?? 'wallet_client', + result: error?.error || error?.data || (error?.reason && { message: error.reason }) || error, + }); + setLoading(false); + }) + .finally(() => { + mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, { + 'Method type': methodType === 'write' ? 'Write' : 'Read', + 'Method name': 'name' in data ? data.name : 'Fallback', + }); + }); + }, [ data, methodType, onSubmit ]); + + const handleTxSettle = React.useCallback(() => { + setLoading(false); + }, []); + + const handleFormChange = React.useCallback(() => { + result && setResult(undefined); + }, [ result ]); + + const inputs: AbiFunction['inputs'] = React.useMemo(() => { + return [ + ...('inputs' in data && data.inputs ? data.inputs : []), + ...('stateMutability' in data && data.stateMutability === 'payable' ? [ { + name: `Send native ${ config.chain.currency.symbol || 'coin' }`, + type: 'uint256' as const, + internalType: 'uint256' as const, + fieldType: 'native_coin' as const, + } ] : []), + ]; + }, [ data ]); + + 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 ( + + + + + { inputs.map((input, index) => { + const props = { + data: input, + basePath: `${ index }`, + isDisabled: isLoading, + level: 0, + }; + + if ('components' in input && input.components && input.type === 'tuple') { + return ; + } + + const arrayMatch = matchArray(input.type); + if (arrayMatch) { + if (arrayMatch.isNested) { + const fieldsWithErrors = Object.keys(formApi.formState.errors); + const isInvalid = fieldsWithErrors.some((field) => field.startsWith(index + ':')); + + return ( + + + + ); + + } + + return ; + } + + return ; + }) } + + { callStrategies.secondary && ( + + ) } + + + + + + { 'outputs' in data && Boolean(data.outputs?.length) && } + { result && } + + ); +}; + +export default React.memo(ContractMethodForm) as typeof ContractMethodForm; diff --git a/ui/address/contract/methodForm/ContractMethodMultiplyButton.tsx b/ui/address/contract/ABI/form/ContractMethodMultiplyButton.tsx similarity index 100% rename from ui/address/contract/methodForm/ContractMethodMultiplyButton.tsx rename to ui/address/contract/ABI/form/ContractMethodMultiplyButton.tsx diff --git a/ui/address/contract/methodForm/ContractMethodFormOutputs.tsx b/ui/address/contract/ABI/form/ContractMethodOutputs.tsx similarity index 75% rename from ui/address/contract/methodForm/ContractMethodFormOutputs.tsx rename to ui/address/contract/ABI/form/ContractMethodOutputs.tsx index 9876266514..f145cd3f20 100644 --- a/ui/address/contract/methodForm/ContractMethodFormOutputs.tsx +++ b/ui/address/contract/ABI/form/ContractMethodOutputs.tsx @@ -1,15 +1,14 @@ import { Flex, chakra } from '@chakra-ui/react'; import React from 'react'; - -import type { SmartContractMethodOutput } from 'types/api/contract'; +import type { AbiFunction } from 'viem'; import IconSvg from 'ui/shared/IconSvg'; interface Props { - data: Array; + data: AbiFunction['outputs']; } -const ContractMethodFormOutputs = ({ data }: Props) => { +const ContractMethodOutputs = ({ data }: Props) => { if (data.length === 0) { return null; } @@ -32,4 +31,4 @@ const ContractMethodFormOutputs = ({ data }: Props) => { ); }; -export default React.memo(ContractMethodFormOutputs); +export default React.memo(ContractMethodOutputs); diff --git a/ui/address/contract/ABI/form/ContractMethodResult.tsx b/ui/address/contract/ABI/form/ContractMethodResult.tsx new file mode 100644 index 0000000000..654d7236c3 --- /dev/null +++ b/ui/address/contract/ABI/form/ContractMethodResult.tsx @@ -0,0 +1,29 @@ +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 ; + + case 'wallet_client': + return ; + + default: { + return null; + } + } +}; + +export default React.memo(ContractMethodResult); diff --git a/ui/address/contract/ContractReadResult.pw.tsx b/ui/address/contract/ABI/form/ContractMethodResultApi.pw.tsx similarity index 52% rename from ui/address/contract/ContractReadResult.pw.tsx rename to ui/address/contract/ABI/form/ContractMethodResultApi.pw.tsx index 35b4991a0c..beeb18b30f 100644 --- a/ui/address/contract/ContractReadResult.pw.tsx +++ b/ui/address/contract/ABI/form/ContractMethodResultApi.pw.tsx @@ -1,69 +1,56 @@ -import { test, expect } from '@playwright/experimental-ct-react'; import React from 'react'; -import type { ContractMethodReadResult } from './types'; +import type { FormSubmitResultApi } from '../types'; 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 onSettle = () => Promise.resolve(); test.use({ viewport: { width: 500, height: 500 } }); -test('default error', async({ mount }) => { - const result: ContractMethodReadResult = { +test('default error', async({ render }) => { + const result: FormSubmitResultApi['result'] = { is_error: true, result: { error: 'I am an error', }, }; - const component = await mount( - - - , - ); + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('error with code', async({ mount }) => { - const result: ContractMethodReadResult = { +test('error with code', async({ render }) => { + const result: FormSubmitResultApi['result'] = { is_error: true, result: { message: 'I am an error', code: -32017, }, }; - const component = await mount( - - - , - ); + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('raw error', async({ mount }) => { - const result: ContractMethodReadResult = { +test('raw error', async({ render }) => { + const result: FormSubmitResultApi['result'] = { is_error: true, result: { raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72', }, }; - const component = await mount( - - - , - ); + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('complex error', async({ mount }) => { - const result: ContractMethodReadResult = { +test('complex error', async({ render }) => { + const result: FormSubmitResultApi['result'] = { is_error: true, result: { method_call: 'SomeCustomError(address addr, uint256 balance)', @@ -74,34 +61,26 @@ test('complex error', async({ mount }) => { ], }, }; - const component = await mount( - - - , - ); + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('success', async({ mount }) => { - const result: ContractMethodReadResult = { +test('success', async({ render }) => { + const result: FormSubmitResultApi['result'] = { is_error: false, result: { names: [ 'address' ], output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ], }, }; - const component = await mount( - - - , - ); + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('complex success', async({ mount }) => { - const result: ContractMethodReadResult = { +test('complex success', async({ render }) => { + const result: FormSubmitResultApi['result'] = { is_error: false, result: { names: [ @@ -122,11 +101,7 @@ test('complex success', async({ mount }) => { ], }, }; - const component = await mount( - - - , - ); + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/address/contract/ABI/form/ContractMethodResultApi.tsx b/ui/address/contract/ABI/form/ContractMethodResultApi.tsx new file mode 100644 index 0000000000..ba8e5fd928 --- /dev/null +++ b/ui/address/contract/ABI/form/ContractMethodResultApi.tsx @@ -0,0 +1,64 @@ +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 { result.statusText }; + } + + if (result instanceof Error) { + return { result.message }; + } + + if (result.is_error) { + if ('error' in result.result) { + return { result.result.error }; + } + + if ('message' in result.result) { + return [{ result.result.code }] { result.result.message }; + } + + if ('raw' in result.result) { + return { `Revert reason: ${ hexToUtf8(result.result.raw) }` }; + } + + if ('method_id' in result.result) { + return { JSON.stringify(result.result, undefined, 2) }; + } + + return Something went wrong.; + } + + return ( + +

+ [ { 'name' in item ? item.name : '' } method response ] +

+

[

+ { result.result.output.map((output, index) => ) } +

]

+ + ); +}; + +export default React.memo(ContractMethodResultApi); diff --git a/ui/address/contract/ABI/form/ContractMethodResultApiError.tsx b/ui/address/contract/ABI/form/ContractMethodResultApiError.tsx new file mode 100644 index 0000000000..bbcfacf407 --- /dev/null +++ b/ui/address/contract/ABI/form/ContractMethodResultApiError.tsx @@ -0,0 +1,16 @@ +import { Alert } from '@chakra-ui/react'; +import React from 'react'; + +interface Props { + children: React.ReactNode; +} + +const ContractMethodResultApiError = ({ children }: Props) => { + return ( + + { children } + + ); +}; + +export default React.memo(ContractMethodResultApiError); diff --git a/ui/address/contract/ABI/form/ContractMethodResultApiItem.tsx b/ui/address/contract/ABI/form/ContractMethodResultApiItem.tsx new file mode 100644 index 0000000000..e1d0bacaf6 --- /dev/null +++ b/ui/address/contract/ABI/form/ContractMethodResultApiItem.tsx @@ -0,0 +1,45 @@ +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 ( + <> +

+ { structName } + ({ output.type }) : +

+ { argNames.map((argName, argIndex) => { + return ( +

+ { argName } + { argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) } +

+ ); + }) } + + ); + } + + return ( +

+ + { name && { name } } + ({ output.type }) : { String(output.value) } +

+ ); +}; + +export default React.memo(ContractMethodResultApiItem); diff --git a/ui/address/contract/ContractWriteResultDumb.pw.tsx b/ui/address/contract/ABI/form/ContractMethodResultWalletClient.pw.tsx similarity index 59% rename from ui/address/contract/ContractWriteResultDumb.pw.tsx rename to ui/address/contract/ABI/form/ContractMethodResultWalletClient.pw.tsx index e916ab90c6..f8bb6b6c99 100644 --- a/ui/address/contract/ContractWriteResultDumb.pw.tsx +++ b/ui/address/contract/ABI/form/ContractMethodResultWalletClient.pw.tsx @@ -1,53 +1,43 @@ -import { test, expect } from '@playwright/experimental-ct-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 = { txInfo: { status: 'pending' as const, error: null, - }, + } as PropsDumb['txInfo'], result: { hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, }, onSettle: () => {}, }; - const component = await mount( - - - , - ); - + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('success', async({ mount }) => { +test('success', async({ render }) => { const props = { txInfo: { status: 'success' as const, error: null, - }, + } as PropsDumb['txInfo'], result: { hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, }, onSettle: () => {}, }; - const component = await mount( - - - , - ); - + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('error +@mobile', async({ mount }) => { +test('error +@mobile', async({ render }) => { const props = { txInfo: { status: 'error' as const, @@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => { // 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 ]', } as Error, - }, + } as PropsDumb['txInfo'], result: { hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, }, onSettle: () => {}, }; - const component = await mount( - - - , - ); - + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('error in result', async({ mount }) => { +test('error in result', async({ render }) => { const props = { txInfo: { status: 'idle' as const, error: null, - }, + } as unknown as PropsDumb['txInfo'], result: { message: 'wallet is not connected', } as Error, onSettle: () => {}, }; - const component = await mount( - - - , - ); - + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/address/contract/ContractWriteResultDumb.tsx b/ui/address/contract/ABI/form/ContractMethodResultWalletClient.tsx similarity index 65% rename from ui/address/contract/ContractWriteResultDumb.tsx rename to ui/address/contract/ABI/form/ContractMethodResultWalletClient.tsx index 3b898fb570..371b914205 100644 --- a/ui/address/contract/ContractWriteResultDumb.tsx +++ b/ui/address/contract/ABI/form/ContractMethodResultWalletClient.tsx @@ -1,22 +1,35 @@ -import { Box, chakra, Spinner } from '@chakra-ui/react'; +import { chakra, Spinner, Box } from '@chakra-ui/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 LinkInternal from 'ui/shared/LinkInternal'; interface Props { - result: ContractMethodWriteResult; + result: FormSubmitResultWalletClient['result']; 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 ; +}; + +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; React.useEffect(() => { @@ -93,4 +106,4 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { ); }; -export default React.memo(ContractWriteResultDumb); +export default React.memo(ContractMethodResultWalletClient); diff --git a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png similarity index 91% rename from ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png rename to ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png index 947ab4a899..85a1ffa319 100644 Binary files a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png similarity index 87% rename from ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png rename to ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png index b600b4167c..89230b3a16 100644 Binary files a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png similarity index 86% rename from ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png rename to ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png index 1cf1fca155..8aba610638 100644 Binary files a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_complex-error-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_complex-error-1.png new file mode 100644 index 0000000000..b96ceac2e2 Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_complex-error-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_complex-success-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_complex-success-1.png new file mode 100644 index 0000000000..d5902ddf0e Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_complex-success-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_default-error-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_default-error-1.png new file mode 100644 index 0000000000..cd23d7452d Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_default-error-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_error-with-code-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_error-with-code-1.png new file mode 100644 index 0000000000..e5d2fcf25c Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_error-with-code-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_raw-error-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_raw-error-1.png new file mode 100644 index 0000000000..241bb2f7e3 Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_raw-error-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_success-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_success-1.png new file mode 100644 index 0000000000..ab0f9ab157 Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultApi.pw.tsx_default_success-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_error-in-result-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_error-in-result-1.png new file mode 100644 index 0000000000..0d50122e57 Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_error-in-result-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_error-mobile-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_error-mobile-1.png new file mode 100644 index 0000000000..79e30aeaa0 Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_error-mobile-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_loading-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_loading-1.png new file mode 100644 index 0000000000..d80cc80e2f Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_loading-1.png differ diff --git a/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_success-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_success-1.png new file mode 100644 index 0000000000..4bd45c7a0b Binary files /dev/null and b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_default_success-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_mobile_error-mobile-1.png b/ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_mobile_error-mobile-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_mobile_error-mobile-1.png rename to ui/address/contract/ABI/form/__screenshots__/ContractMethodResultWalletClient.pw.tsx_mobile_error-mobile-1.png diff --git a/ui/address/contract/methodForm/useFormatFieldValue.tsx b/ui/address/contract/ABI/form/useFormatFieldValue.tsx similarity index 90% rename from ui/address/contract/methodForm/useFormatFieldValue.tsx rename to ui/address/contract/ABI/form/useFormatFieldValue.tsx index 2a54da6ab0..70bd8270fe 100644 --- a/ui/address/contract/methodForm/useFormatFieldValue.tsx +++ b/ui/address/contract/ABI/form/useFormatFieldValue.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import type { SmartContractMethodArgType } from 'types/api/contract'; - import type { MatchInt } from './utils'; interface Params { - argType: SmartContractMethodArgType; + argType: string; argTypeMatchInt: MatchInt | null; } diff --git a/ui/address/contract/methodForm/useValidateField.tsx b/ui/address/contract/ABI/form/useValidateField.tsx similarity index 95% rename from ui/address/contract/methodForm/useValidateField.tsx rename to ui/address/contract/ABI/form/useValidateField.tsx index 60300c6121..de9d506f31 100644 --- a/ui/address/contract/methodForm/useValidateField.tsx +++ b/ui/address/contract/ABI/form/useValidateField.tsx @@ -1,13 +1,11 @@ import React from 'react'; import { getAddress, isAddress, isHex } from 'viem'; -import type { SmartContractMethodArgType } from 'types/api/contract'; - import type { MatchInt } from './utils'; import { BYTES_REGEXP } from './utils'; interface Params { - argType: SmartContractMethodArgType; + argType: string; argTypeMatchInt: MatchInt | null; isOptional: boolean; } diff --git a/ui/address/contract/methodForm/utils.test.ts b/ui/address/contract/ABI/form/utils.test.ts similarity index 100% rename from ui/address/contract/methodForm/utils.test.ts rename to ui/address/contract/ABI/form/utils.test.ts diff --git a/ui/address/contract/methodForm/utils.ts b/ui/address/contract/ABI/form/utils.ts similarity index 79% rename from ui/address/contract/methodForm/utils.ts rename to ui/address/contract/ABI/form/utils.ts index a0a136134e..55abd78f1c 100644 --- a/ui/address/contract/methodForm/utils.ts +++ b/ui/address/contract/ABI/form/utils.ts @@ -1,6 +1,6 @@ import _set from 'lodash/set'; -import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract'; +import type { ContractAbiItemInput } from '../types'; export type ContractMethodFormFields = Record; @@ -11,22 +11,22 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i; export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/; export interface MatchArray { - itemType: SmartContractMethodArgType; + itemType: string; size: number; isNested: boolean; } -export const matchArray = (argType: SmartContractMethodArgType): MatchArray | null => { +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 as SmartContractMethodArgType)); + const isNested = Boolean(matchArray(itemType)); return { - itemType: itemType as SmartContractMethodArgType, + itemType, size: size ? Number(size) : Infinity, isNested, }; @@ -39,7 +39,7 @@ export interface MatchInt { max: bigint; } -export const matchInt = (argType: SmartContractMethodArgType): MatchInt | null => { +export const matchInt = (argType: string): MatchInt | null => { const match = argType.match(INT_REGEXP); if (!match) { return null; @@ -51,9 +51,9 @@ export const matchInt = (argType: SmartContractMethodArgType): MatchInt | null = return { isUnsigned: Boolean(isUnsigned), power, min, max }; }; -export const transformDataForArrayItem = (data: SmartContractMethodInput, index: number): SmartContractMethodInput => { +export const transformDataForArrayItem = (data: ContractAbiItemInput, index: number): ContractAbiItemInput => { const arrayMatchType = matchArray(data.type); - const arrayMatchInternalType = data.internalType ? matchArray(data.internalType as SmartContractMethodArgType) : null; + const arrayMatchInternalType = data.internalType ? matchArray(data.internalType) : null; const childrenInternalType = arrayMatchInternalType?.itemType.replaceAll('struct ', ''); const postfix = childrenInternalType ? ' ' + childrenInternalType : ''; @@ -97,7 +97,7 @@ function filterOurEmptyItems(array: Array): Array { .filter((item) => item !== undefined); } -export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) { +export function getFieldLabel(input: ContractAbiItemInput, isRequired?: boolean) { const name = input.name || input.internalType || ''; return `${ name } (${ input.type })${ isRequired ? '*' : '' }`; } diff --git a/ui/address/contract/ABI/types.ts b/ui/address/contract/ABI/types.ts new file mode 100644 index 0000000000..7268edee30 --- /dev/null +++ b/ui/address/contract/ABI/types.ts @@ -0,0 +1,25 @@ +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; + +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, submitType: MethodCallStrategy | undefined) => Promise; diff --git a/ui/address/contract/ABI/useCallMethodApi.ts b/ui/address/contract/ABI/useCallMethodApi.ts new file mode 100644 index 0000000000..80345a222d --- /dev/null +++ b/ui/address/contract/ABI/useCallMethodApi.ts @@ -0,0 +1,51 @@ +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; + isProxy: boolean; + isCustomAbi: boolean; + addressHash: string; +} + +export default function useCallMethodApi(): (params: Params) => Promise { + 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 ]); +} diff --git a/ui/address/contract/ABI/useCallMethodWalletClient.ts b/ui/address/contract/ABI/useCallMethodWalletClient.ts new file mode 100644 index 0000000000..1ec49b0d32 --- /dev/null +++ b/ui/address/contract/ABI/useCallMethodWalletClient.ts @@ -0,0 +1,70 @@ +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; + addressHash: string; +} + +export default function useCallMethodWalletClient(): (params: Params) => Promise { + 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 ]); +} diff --git a/ui/address/contract/ABI/useFormSubmit.ts b/ui/address/contract/ABI/useFormSubmit.ts new file mode 100644 index 0000000000..a401955658 --- /dev/null +++ b/ui/address/contract/ABI/useFormSubmit.ts @@ -0,0 +1,72 @@ +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; diff --git a/ui/address/contract/ABI/useScrollToMethod.ts b/ui/address/contract/ABI/useScrollToMethod.ts new file mode 100644 index 0000000000..ddeac03d5d --- /dev/null +++ b/ui/address/contract/ABI/useScrollToMethod.ts @@ -0,0 +1,26 @@ +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) => 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 ]); +} diff --git a/ui/address/contract/ABI/utils.ts b/ui/address/contract/ABI/utils.ts new file mode 100644 index 0000000000..46c9fb2c8a --- /dev/null +++ b/ui/address/contract/ABI/utils.ts @@ -0,0 +1,7 @@ +export const getNativeCoinValue = (value: unknown) => { + if (typeof value !== 'string') { + return BigInt(0); + } + + return BigInt(value); +}; diff --git a/ui/address/contract/ContractRead.tsx b/ui/address/contract/ContractRead.tsx index 268b268d43..9bd819025c 100644 --- a/ui/address/contract/ContractRead.tsx +++ b/ui/address/contract/ContractRead.tsx @@ -1,31 +1,24 @@ -import { Alert, Flex } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import React from 'react'; -import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract'; - -import useApiFetch from 'lib/api/useApiFetch'; +import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; 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 DataFetchAlert from 'ui/shared/DataFetchAlert'; +import ContractAbi from './ABI/ContractAbi'; import ContractConnectWallet from './ContractConnectWallet'; import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractImplementationAddress from './ContractImplementationAddress'; -import ContractMethodConstant from './ContractMethodConstant'; -import ContractReadResult from './ContractReadResult'; -import ContractMethodForm from './methodForm/ContractMethodForm'; -import useWatchAccount from './useWatchAccount'; interface Props { isLoading?: boolean; } const ContractRead = ({ isLoading }: Props) => { - const apiFetch = useApiFetch(); - const account = useWatchAccount(); + const { address } = useAccount(); const router = useRouter(); const tab = getQueryParamString(router.query.tab); @@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => { pathParams: { hash: addressHash }, queryParams: { is_custom_abi: isCustomAbi ? 'true' : 'false', - from: account?.address, + from: address, }, queryOptions: { enabled: !isLoading, }, }); - const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array) => { - 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 { item.error }; - } - - if (item.outputs?.some(({ value }) => value !== undefined && value !== null)) { - return ( - - { item.outputs.map((output, index) => ) } - - ); - } - - return ( - - ); - }, [ handleMethodFormSubmit ]); - if (isError) { return ; } @@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => { return ( <> { isCustomAbi && } - { account && } + { config.features.blockchainInteraction.isEnabled && } { isProxy && } - + ); }; diff --git a/ui/address/contract/ContractReadResult.tsx b/ui/address/contract/ContractReadResult.tsx deleted file mode 100644 index 28f701eb9b..0000000000 --- a/ui/address/contract/ContractReadResult.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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 ( - - { children } - - ); -}; - -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 ( - <> -

- { structName } - ({ output.type }) : -

- { argNames.map((argName, argIndex) => { - return ( -

- { argName } - { argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) } -

- ); - }) } - - ); - } - - return ( -

- - { name && { name } } - ({ output.type }) : { String(output.value) } -

- ); -}; - -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 { result.statusText }; - } - - if (result.is_error) { - if ('error' in result.result) { - return { result.result.error }; - } - - if ('message' in result.result) { - return [{ result.result.code }] { result.result.message }; - } - - if ('raw' in result.result) { - return { `Revert reason: ${ hexToUtf8(result.result.raw) }` }; - } - - if ('method_id' in result.result) { - return { JSON.stringify(result.result, undefined, 2) }; - } - - return Something went wrong.; - } - - return ( - -

- [ { 'name' in item ? item.name : '' } method response ] -

-

[

- { result.result.output.map((output, index) => ) } -

]

-
- ); -}; - -export default React.memo(ContractReadResult); diff --git a/ui/address/contract/ContractWrite.tsx b/ui/address/contract/ContractWrite.tsx index 378aae7c25..b79d8e0a02 100644 --- a/ui/address/contract/ContractWrite.tsx +++ b/ui/address/contract/ContractWrite.tsx @@ -1,33 +1,22 @@ import { useRouter } from 'next/router'; import React from 'react'; -import { useAccount, useWalletClient, useSwitchChain } from 'wagmi'; - -import type { SmartContractWriteMethod } from 'types/api/contract'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; import getQueryParamString from 'lib/router/getQueryParamString'; -import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContentLoader from 'ui/shared/ContentLoader'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; +import ContractAbi from './ABI/ContractAbi'; import ContractConnectWallet from './ContractConnectWallet'; import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractImplementationAddress from './ContractImplementationAddress'; -import ContractWriteResult from './ContractWriteResult'; -import ContractMethodForm from './methodForm/ContractMethodForm'; -import useContractAbi from './useContractAbi'; -import { getNativeCoinValue, prepareAbi } from './utils'; interface Props { isLoading?: boolean; } const ContractWrite = ({ isLoading }: Props) => { - const { data: walletClient } = useWalletClient(); - const { isConnected, chainId } = useAccount(); - const { switchChainAsync } = useSwitchChain(); - const router = useRouter(); const tab = getQueryParamString(router.query.tab); @@ -46,63 +35,6 @@ const ContractWrite = ({ isLoading }: Props) => { }, }); - const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi }); - - const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array) => { - 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 ( - - ); - }, [ handleMethodFormSubmit ]); - if (isError) { return ; } @@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => { return ( <> { isCustomAbi && } - + { config.features.blockchainInteraction.isEnabled && } { isProxy && } - + ); }; diff --git a/ui/address/contract/ContractWriteResult.tsx b/ui/address/contract/ContractWriteResult.tsx deleted file mode 100644 index 3cc755d131..0000000000 --- a/ui/address/contract/ContractWriteResult.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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) => { - const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined; - const txInfo = useWaitForTransactionReceipt({ - hash: txHash, - }); - - return ; -}; - -export default React.memo(ContractWriteResult) as typeof ContractWriteResult; diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png index f19ba84553..9ca2b3c467 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png index 9402cd11fc..3fb74dce71 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png index 82900c2f57..fe2ffcfcba 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png index 635406e6f5..171aa0aad1 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png index 930ca02a9e..3d31997d9d 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png index b5d534a0d0..77c8176a15 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_complex-error-1.png b/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_complex-error-1.png deleted file mode 100644 index 80741a58e9..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_complex-error-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_complex-success-1.png b/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_complex-success-1.png deleted file mode 100644 index 695b1df2c6..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_complex-success-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_default-error-1.png b/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_default-error-1.png deleted file mode 100644 index 31c2a142c1..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_default-error-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_error-with-code-1.png b/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_error-with-code-1.png deleted file mode 100644 index 680fbde860..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_error-with-code-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_raw-error-1.png b/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_raw-error-1.png deleted file mode 100644 index dfbf8524ab..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_raw-error-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_success-1.png b/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_success-1.png deleted file mode 100644 index 0010723b55..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractReadResult.pw.tsx_default_success-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png index e58e9c4755..19741ab173 100644 Binary files a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png and b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png index c9a99a0bfb..20cf83a691 100644 Binary files a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png and b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_error-in-result-1.png b/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_error-in-result-1.png deleted file mode 100644 index 30a20b5510..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_error-in-result-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_error-mobile-1.png b/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_error-mobile-1.png deleted file mode 100644 index e9ec6c1578..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_error-mobile-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_loading-1.png b/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_loading-1.png deleted file mode 100644 index 986269100c..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_loading-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_success-1.png b/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_success-1.png deleted file mode 100644 index 49697a5f42..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractWriteResultDumb.pw.tsx_default_success-1.png and /dev/null differ diff --git a/ui/address/contract/methodForm/ContractMethodForm.tsx b/ui/address/contract/methodForm/ContractMethodForm.tsx deleted file mode 100644 index 7523e6782d..0000000000 --- a/ui/address/contract/methodForm/ContractMethodForm.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Box, Button, Flex, chakra } from '@chakra-ui/react'; -import _mapValues from 'lodash/mapValues'; -import React from 'react'; -import type { SubmitHandler } from 'react-hook-form'; -import { useForm, FormProvider } from 'react-hook-form'; - -import type { ContractMethodCallResult } from '../types'; -import type { ResultComponentProps } from './types'; -import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract'; - -import config from 'configs/app'; -import * as mixpanel from 'lib/mixpanel/index'; - -import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; -import ContractMethodFieldInput from './ContractMethodFieldInput'; -import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; -import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; -import ContractMethodFormOutputs from './ContractMethodFormOutputs'; -import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils'; -import type { ContractMethodFormFields } from './utils'; - -interface Props { - data: T; - onSubmit: (data: T, args: Array) => Promise>; - resultComponent: (props: ResultComponentProps) => JSX.Element | null; - methodType: 'read' | 'write'; -} - -const ContractMethodForm = ({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props) => { - - const [ result, setResult ] = React.useState>(); - const [ isLoading, setLoading ] = React.useState(false); - - const formApi = useForm({ - mode: 'all', - shouldUnregister: true, - }); - - const onFormSubmit: SubmitHandler = React.useCallback(async(formData) => { - // The API used for reading from contracts expects all values to be strings. - const formattedData = methodType === 'read' ? - _mapValues(formData, (value) => value !== undefined ? String(value) : undefined) : - formData; - const args = transformFormDataToMethodArgs(formattedData); - - setResult(undefined); - setLoading(true); - - onSubmit(data, args) - .then((result) => { - setResult(result); - }) - .catch((error) => { - setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error); - setLoading(false); - }) - .finally(() => { - mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, { - 'Method type': methodType === 'write' ? 'Write' : 'Read', - 'Method name': 'name' in data ? data.name : 'Fallback', - }); - }); - }, [ data, methodType, onSubmit ]); - - const handleTxSettle = React.useCallback(() => { - setLoading(false); - }, []); - - const handleFormChange = React.useCallback(() => { - result && setResult(undefined); - }, [ result ]); - - const inputs: Array = React.useMemo(() => { - return [ - ...('inputs' in data ? data.inputs : []), - ...('stateMutability' in data && data.stateMutability === 'payable' ? [ { - name: `Send native ${ config.chain.currency.symbol || 'coin' }`, - type: 'uint256' as const, - internalType: 'uint256' as const, - fieldType: 'native_coin' as const, - } ] : []), - ]; - }, [ data ]); - - const outputs = 'outputs' in data && data.outputs ? data.outputs : []; - - return ( - - - - - { inputs.map((input, index) => { - if (input.components && input.type === 'tuple') { - return ; - } - - const arrayMatch = matchArray(input.type); - - if (arrayMatch) { - if (arrayMatch.isNested) { - const fieldsWithErrors = Object.keys(formApi.formState.errors); - const isInvalid = fieldsWithErrors.some((field) => field.startsWith(index + ':')); - - return ( - - - - ); - } - - return ; - } - - return ; - }) } - - - - - { methodType === 'read' && } - { result && } - - ); -}; - -export default React.memo(ContractMethodForm) as typeof ContractMethodForm; diff --git a/ui/address/contract/methodForm/types.ts b/ui/address/contract/methodForm/types.ts deleted file mode 100644 index 845d6d3621..0000000000 --- a/ui/address/contract/methodForm/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ContractMethodCallResult } from '../types'; -import type { SmartContractMethod } from 'types/api/contract'; - -export interface ResultComponentProps { - item: T; - result: ContractMethodCallResult; - onSettle: () => void; -} diff --git a/ui/address/contract/types.ts b/ui/address/contract/types.ts deleted file mode 100644 index 26753dcbf1..0000000000 --- a/ui/address/contract/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract'; - -import type { ResourceError } from 'lib/api/resources'; - -export type MethodFormFields = Record>; -export type MethodFormFieldsFormatted = Record; - -export type MethodArgType = string | boolean | Array; - -export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError; - -export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined; - -export type ContractMethodCallResult = - T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult; diff --git a/ui/address/contract/useContractAbi.tsx b/ui/address/contract/useContractAbi.tsx deleted file mode 100644 index e9d9ff8e16..0000000000 --- a/ui/address/contract/useContractAbi.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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
(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 ]); -} diff --git a/ui/address/contract/useWatchAccount.tsx b/ui/address/contract/useWatchAccount.tsx deleted file mode 100644 index ac19b2f55e..0000000000 --- a/ui/address/contract/useWatchAccount.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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; -} diff --git a/ui/address/contract/utils.test.ts b/ui/address/contract/utils.test.ts deleted file mode 100644 index 47e12d3b67..0000000000 --- a/ui/address/contract/utils.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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]); - }); -}); diff --git a/ui/address/contract/utils.ts b/ui/address/contract/utils.ts deleted file mode 100644 index 8fa04e839c..0000000000 --- a/ui/address/contract/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -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; -} diff --git a/ui/block/BlockDetails.pw.tsx b/ui/block/BlockDetails.pw.tsx index 65b7f7c5c3..a10a06ef5a 100644 --- a/ui/block/BlockDetails.pw.tsx +++ b/ui/block/BlockDetails.pw.tsx @@ -1,10 +1,8 @@ import React from 'react'; 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 TestApp from 'playwright/TestApp'; -import * as configs from 'playwright/utils/configs'; import BlockDetails from './BlockDetails'; import type { BlockQuery } from './useBlockQuery'; @@ -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 = { data: blockMock.base, isPending: false, } as BlockQuery; - const component = await mount( - - - , - { hooksConfig }, - ); + const component = await render(, { hooksConfig }); await page.getByText('View details').click(); await expect(component).toHaveScreenshot(); }); -test('genesis block', async({ mount, page }) => { +test('genesis block', async({ render, page }) => { const query = { data: blockMock.genesis, isPending: false, } as BlockQuery; - const component = await mount( - - - , - { hooksConfig }, - ); + const component = await render(, { hooksConfig }); await page.getByText('View details').click(); await expect(component).toHaveScreenshot(); }); -test('with blob txs', async({ mount, page, mockEnvs }) => { +test('with blob txs', async({ render, page, mockEnvs }) => { await mockEnvs([ [ 'NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED', 'true' ], ]); @@ -60,35 +48,21 @@ test('with blob txs', async({ mount, page, mockEnvs }) => { isPending: false, } as BlockQuery; - const component = await mount( - - - , - { hooksConfig }, - ); + const component = await render(, { hooksConfig }); await page.getByText('View details').click(); await expect(component).toHaveScreenshot(); }); -const customFieldsTest = test.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any, -}); - -customFieldsTest('rootstock custom fields', async({ mount, page }) => { +test('rootstock custom fields', async({ render, page, mockEnvs }) => { + await mockEnvs(ENVS_MAP.blockHiddenFields); const query = { data: blockMock.rootstock, isPending: false, } as BlockQuery; - const component = await mount( - - - , - { hooksConfig }, - ); + const component = await render(, { hooksConfig }); await page.getByText('View details').click(); diff --git a/ui/block/BlockDetails.tsx b/ui/block/BlockDetails.tsx index c876124f02..49777319f4 100644 --- a/ui/block/BlockDetails.tsx +++ b/ui/block/BlockDetails.tsx @@ -220,28 +220,28 @@ const BlockDetails = ({ query }: Props) => { ) } - { rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && ( - <> - - { data.zksync.batch_number ? ( - - ) : Pending } - - - - - + { rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && !config.UI.views.block.hiddenFields?.batch && ( + + { data.zksync.batch_number ? ( + + ) : Pending } + + ) } + { rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && !config.UI.views.block.hiddenFields?.L1_status && ( + + + ) } { !config.UI.views.block.hiddenFields?.miner && ( diff --git a/ui/gasTracker/GasTrackerPriceSnippet.pw.tsx b/ui/gasTracker/GasTrackerPriceSnippet.pw.tsx index 77ae96cad3..9700c43528 100644 --- a/ui/gasTracker/GasTrackerPriceSnippet.pw.tsx +++ b/ui/gasTracker/GasTrackerPriceSnippet.pw.tsx @@ -1,9 +1,7 @@ -import { test, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import * as statsMock from 'mocks/stats/index'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; +import { test, expect } from 'playwright/lib'; import * as configs from 'playwright/utils/configs'; import GasTrackerPriceSnippet from './GasTrackerPriceSnippet'; @@ -13,48 +11,57 @@ test.use({ viewport: configs.viewport.md }); const data = statsMock.base.gas_prices.fast; const clip = { x: 0, y: 0, width: 334, height: 204 }; -test('with usd as primary unit +@dark-mode', async({ mount, page }) => { - await mount( - - - , +test('with usd as primary unit +@dark-mode', async({ render, page }) => { + await render( + , ); await expect(page).toHaveScreenshot({ clip }); }); -test('loading state', async({ mount, page }) => { - await mount( - - - , +test('loading state', async({ render, page }) => { + await render( + , ); await expect(page).toHaveScreenshot({ clip }); }); -const gweiUnitsTest = test.extend({ - context: contextWithEnvs([ - { name: 'NEXT_PUBLIC_GAS_TRACKER_UNITS', value: '["gwei","usd"]' }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ]) as any, +test('with gwei as primary unit +@dark-mode', async({ render, page, mockEnvs }) => { + await mockEnvs([ + [ 'NEXT_PUBLIC_GAS_TRACKER_UNITS', '["gwei","usd"]' ], + ]); + await render( + , + ); + await expect(page).toHaveScreenshot({ clip }); }); -gweiUnitsTest('with gwei as primary unit +@dark-mode', async({ mount, page }) => { - await mount( - - - , +test('with zero values', async({ render, page }) => { + const data = { + fiat_price: '1.74', + price: 0.0, + time: 0, + base_fee: 0, + priority_fee: 0, + }; + + await render( + , ); await expect(page).toHaveScreenshot({ clip }); }); diff --git a/ui/gasTracker/GasTrackerPriceSnippet.tsx b/ui/gasTracker/GasTrackerPriceSnippet.tsx index 0cae78edca..82a071f103 100644 --- a/ui/gasTracker/GasTrackerPriceSnippet.tsx +++ b/ui/gasTracker/GasTrackerPriceSnippet.tsx @@ -50,14 +50,14 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => { - { data.price && data.fiat_price && } + { data.price !== null && data.fiat_price !== null && } per transaction { typeof data.time === 'number' && data.time > 0 && / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s } - { data.base_fee && Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) } } - { data.base_fee && data.priority_fee && / } - { data.priority_fee && Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) } } + { typeof data.base_fee === 'number' && Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) } } + { typeof data.base_fee === 'number' && typeof data.priority_fee === 'number' && / } + { typeof data.priority_fee === 'number' && Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) } } ); diff --git a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-zero-values-1.png b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-zero-values-1.png new file mode 100644 index 0000000000..b5fb82273f Binary files /dev/null and b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-zero-values-1.png differ diff --git a/ui/home/LatestBlocks.pw.tsx b/ui/home/LatestBlocks.pw.tsx index 140c7e6af9..0852902c50 100644 --- a/ui/home/LatestBlocks.pw.tsx +++ b/ui/home/LatestBlocks.pw.tsx @@ -1,145 +1,49 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import * as blockMock from 'mocks/blocks/block'; 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 TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { test, expect } from 'playwright/lib'; import LatestBlocks from './LatestBlocks'; -const STATS_API_URL = buildApiUrl('stats'); -const BLOCKS_API_URL = buildApiUrl('homepage_blocks'); - -export const test = base.extend({ - 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( - - - , - ); - +test('default view +@mobile +@dark-mode', async({ render, mockApiResponse }) => { + await mockApiResponse('stats', statsMock.base); + await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]); + const component = await render(); await expect(component).toHaveScreenshot(); }); -const testL2 = test.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, -}); - -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( - - - , - ); - +test('L2 view', async({ render, mockEnvs, mockApiResponse }) => { + await mockEnvs(ENVS_MAP.optimisticRollup); + await mockApiResponse('stats', statsMock.base); + await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]); + const component = await render(); await expect(component).toHaveScreenshot(); }); -const testNoReward = test.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any, -}); - -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( - - - , - ); - +test('no reward view', async({ render, mockEnvs, mockApiResponse }) => { + await mockEnvs(ENVS_MAP.blockHiddenFields); + await mockApiResponse('stats', statsMock.base); + await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]); + const component = await render(); await expect(component).toHaveScreenshot(); }); -test('with long block height', 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, - height: 123456789012345, - }, - ]), - })); - - const component = await mount( - - - , - ); - +test('with long block height', async({ render, mockApiResponse }) => { + await mockApiResponse('stats', statsMock.base); + await mockApiResponse('homepage_blocks', [ { ...blockMock.base, height: 123456789012345 } ]); + const component = await render(); await expect(component).toHaveScreenshot(); }); test.describe('socket', () => { test.describe.configure({ mode: 'serial' }); - - test('new item', async({ mount, page, createSocket }) => { - 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( - - - , - ); - + test('new item', async({ render, mockApiResponse, createSocket }) => { + await mockApiResponse('stats', statsMock.base); + await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]); + const component = await render(, undefined, { withSocket: true }); const socket = await createSocket(); const channel = await socketServer.joinChannel(socket, 'blocks:new_block'); socketServer.sendMessage(socket, channel, 'new_block', { @@ -150,7 +54,6 @@ test.describe('socket', () => { timestamp: '2022-11-11T11:59:58Z', }, }); - await expect(component).toHaveScreenshot(); }); }); diff --git a/ui/home/LatestDeposits.pw.tsx b/ui/home/LatestDeposits.pw.tsx index 44886e6496..f939adb3d9 100644 --- a/ui/home/LatestDeposits.pw.tsx +++ b/ui/home/LatestDeposits.pw.tsx @@ -1,30 +1,14 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import * as depositMock from 'mocks/l2deposits/deposits'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import LatestDeposits from './LatestDeposits'; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, -}); - -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( - - - , - ); - +test('default view +@mobile +@dark-mode', async({ render, mockApiResponse, mockEnvs }) => { + await mockEnvs(ENVS_MAP.optimisticRollup); + mockApiResponse('homepage_deposits', depositMock.data.items); + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/home/LatestZkEvmL2Batches.pw.tsx b/ui/home/LatestZkEvmL2Batches.pw.tsx index 7e75dde761..47ed65a1a4 100644 --- a/ui/home/LatestZkEvmL2Batches.pw.tsx +++ b/ui/home/LatestZkEvmL2Batches.pw.tsx @@ -1,32 +1,15 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import { txnBatchesData } from 'mocks/zkEvm/txnBatches'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import LatestZkEvmL2Batches from './LatestZkEvmL2Batches'; -const BATCHES_API_URL = buildApiUrl('homepage_zkevm_l2_batches'); - -const test = base.extend({ - // 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( - - - , - ); +test('default view +@mobile +@dark-mode', async({ render, mockEnvs, mockApiResponse }) => { + await mockEnvs(ENVS_MAP.zkEvmRollup); + await mockApiResponse('homepage_zkevm_l2_batches', txnBatchesData); + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/marketplace/EmptySearchResult.tsx b/ui/marketplace/EmptySearchResult.tsx index d3ccf59701..e744768be6 100644 --- a/ui/marketplace/EmptySearchResult.tsx +++ b/ui/marketplace/EmptySearchResult.tsx @@ -20,7 +20,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( text={ (selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? ( <> - You don{ apos }t have any favorite apps. + You don{ apos }t have any favorite apps.
Click on the icon on the app{ apos }s card to add it to Favorites. ) : ( @@ -28,7 +28,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( No matching apps found. { 'suggestIdeasFormUrl' in feature && ( <> - { ' ' }Have a groundbreaking idea or app suggestion?{ ' ' } + { ' ' }Have a groundbreaking idea or app suggestion?
Share it with us ) } diff --git a/ui/marketplace/MarketplaceAppModal.tsx b/ui/marketplace/MarketplaceAppModal.tsx index f20679e407..ff318722b8 100644 --- a/ui/marketplace/MarketplaceAppModal.tsx +++ b/ui/marketplace/MarketplaceAppModal.tsx @@ -7,7 +7,6 @@ import React, { useCallback } from 'react'; import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'; import { ContractListTypes } from 'types/client/marketplace'; -import useFeatureValue from 'lib/growthbook/useFeatureValue'; import useIsMobile from 'lib/hooks/useIsMobile'; import { nbsp } from 'lib/html-entities'; import * as mixpanel from 'lib/mixpanel/index'; @@ -33,7 +32,6 @@ const MarketplaceAppModal = ({ data, showContractList: showContractListProp, }: Props) => { - const { value: isExperiment } = useFeatureValue('security_score_exp', false); const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300'); const { @@ -47,6 +45,7 @@ const MarketplaceAppModal = ({ github, telegram, twitter, + discord, logo, logoDarkMode, categories, @@ -62,6 +61,10 @@ const MarketplaceAppModal = ({ icon: 'social/twitter_filled' as IconName, url: twitter, } : null, + discord ? { + icon: 'social/discord_filled' as IconName, + url: discord, + } : null, ].filter(Boolean); if (github) { @@ -183,7 +186,7 @@ const MarketplaceAppModal = ({ /> - { (isExperiment && securityReport) && ( + { securityReport && ( { const [ showContractList, setShowContractList ] = useBoolean(false); const appProps = useAppContext(); const isMobile = useIsMobile(); - const { value: isExperiment } = useFeatureValue('security_score_exp', false); const goBackUrl = React.useMemo(() => { if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) { @@ -72,7 +70,7 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { - { (isExperiment && (securityReport || isLoading)) && ( + { (securityReport || isLoading) && ( { - const displayedApps = React.useMemo(() => apps.sort((a, b) => { + const displayedApps = React.useMemo(() => [ ...apps ].sort((a, b) => { if (!a.securityReport) { return 1; } else if (!b.securityReport) { diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index 24e9473a3a..d5fba4f1f9 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png index 5bf3a43f18..25dffc6800 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png index 556279757f..63ab8f1099 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/marketplace/useMarketplaceApps.tsx b/ui/marketplace/useMarketplaceApps.tsx index 8aca496b9c..96f09f379e 100644 --- a/ui/marketplace/useMarketplaceApps.tsx +++ b/ui/marketplace/useMarketplaceApps.tsx @@ -18,13 +18,13 @@ function isAppNameMatches(q: string, app: MarketplaceAppWithSecurityReport) { return app.title.toLowerCase().includes(q.toLowerCase()); } -function isAppCategoryMatches(category: string, app: MarketplaceAppWithSecurityReport, favoriteApps: Array) { +function isAppCategoryMatches(category: string, app: MarketplaceAppWithSecurityReport, favoriteApps: Array = []) { return category === MarketplaceCategory.ALL || (category === MarketplaceCategory.FAVORITES && favoriteApps.includes(app.id)) || app.categories.includes(category); } -function sortApps(apps: Array, favoriteApps: Array) { +function sortApps(apps: Array, favoriteApps: Array = []) { return apps.sort((a, b) => { const priorityA = a.priority || 0; const priorityB = b.priority || 0; @@ -60,17 +60,19 @@ export default function useMarketplaceApps( 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 | undefined>(); + const isInitialSetup = React.useRef(true); React.useEffect(() => { - if (isFavoriteAppsLoaded) { - setSnapshotFavoriteApps(favoriteApps); + if (isInitialSetup.current && (isFavoriteAppsLoaded || favoriteApps === undefined)) { + setSnapshotFavoriteApps(favoriteApps || []); + isInitialSetup.current = false; } - }, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps + }, [ isFavoriteAppsLoaded, favoriteApps ]); const { isPlaceholderData, isError, error, data } = useQuery, Array>({ - queryKey: [ 'marketplace-dapps', snapshotFavoriteApps, favoriteApps ], + queryKey: [ 'marketplace-dapps', snapshotFavoriteApps ], queryFn: async() => { if (!feature.isEnabled) { return []; @@ -80,10 +82,10 @@ export default function useMarketplaceApps( return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } }); } }, - select: (data) => sortApps(data as Array, snapshotFavoriteApps || []), + select: (data) => sortApps(data as Array, snapshotFavoriteApps), placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined, staleTime: Infinity, - enabled: feature.isEnabled && (!favoriteApps || Boolean(snapshotFavoriteApps)), + enabled: feature.isEnabled && Boolean(snapshotFavoriteApps), }); const appsWithSecurityReports = React.useMemo(() => @@ -91,7 +93,7 @@ export default function useMarketplaceApps( [ data, securityReports ]); 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 ]); return React.useMemo(() => ({ diff --git a/ui/pages/Address.pw.tsx b/ui/pages/Address.pw.tsx new file mode 100644 index 0000000000..b58cdd7841 --- /dev/null +++ b/ui/pages/Address.pw.tsx @@ -0,0 +1,33 @@ +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(
, { 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(); + }); +}); diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 9dbbdb8a08..6d4c829a96 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -2,14 +2,18 @@ import { Box, Flex, HStack, useColorModeValue } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import React from 'react'; +import type { EntityTag } from 'ui/shared/EntityTags/types'; import type { RoutedTab } from 'ui/shared/Tabs/types'; import config from 'configs/app'; +import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery'; import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; import useContractTabs from 'lib/hooks/useContractTabs'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; 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 { USER_OPS_ACCOUNT } from 'stubs/userOps'; import AddressAccountHistory from 'ui/address/AddressAccountHistory'; @@ -34,7 +38,9 @@ import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu' import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; 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 NetworkExplorers from 'ui/shared/NetworkExplorers'; import PageTitle from 'ui/shared/Page/PageTitle'; @@ -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 safeIconColor = useColorModeValue('black', 'white'); 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 = React.useMemo(() => { return [ { @@ -169,18 +192,27 @@ const AddressPageContent = () => { ].filter(Boolean); }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]); - const tags = ( + const tags: Array = 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 = ( ); @@ -244,7 +276,7 @@ const AddressPageContent = () => { diff --git a/ui/pages/BeaconChainWithdrawals.pw.tsx b/ui/pages/BeaconChainWithdrawals.pw.tsx index ab4cc8fa7a..a445ee1720 100644 --- a/ui/pages/BeaconChainWithdrawals.pw.tsx +++ b/ui/pages/BeaconChainWithdrawals.pw.tsx @@ -1,43 +1,16 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import { data as withdrawalsData } from 'mocks/withdrawals/withdrawals'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import BeaconChainWithdrawals from './BeaconChainWithdrawals'; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.beaconChain) as any, -}); - -const WITHDRAWALS_API_URL = buildApiUrl('withdrawals'); -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( - - - , - ); - +test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { + await mockEnvs(ENVS_MAP.beaconChain); + await mockTextAd(); + await mockApiResponse('withdrawals', withdrawalsData); + await mockApiResponse('withdrawals_counters', { withdrawal_count: '111111', withdrawal_sum: '1010101010110101001101010' }); + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/pages/Blocks.pw.tsx b/ui/pages/Blocks.pw.tsx index 07395a1656..2a334c3b21 100644 --- a/ui/pages/Blocks.pw.tsx +++ b/ui/pages/Blocks.pw.tsx @@ -1,13 +1,10 @@ -import type { BrowserContext } from '@playwright/test'; import React from 'react'; import * as blockMock from 'mocks/blocks/block'; 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 { test, expect, devices } from 'playwright/lib'; -import * as configs from 'playwright/utils/configs'; import Blocks from './Blocks'; @@ -57,11 +54,8 @@ test.describe('mobile', () => { await expect(component).toHaveScreenshot(); }); - const hiddenFieldsTest = test.extend<{ context: BrowserContext }>({ - context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields), - }); - - hiddenFieldsTest('hidden fields', async({ render, mockApiResponse }) => { + test('hidden fields', async({ render, mockApiResponse, mockEnvs }) => { + await mockEnvs(ENVS_MAP.blockHiddenFields); await mockApiResponse('blocks', blockMock.baseListResponse, { queryParams: { type: 'block' } }); await mockApiResponse('stats', statsMock.base); diff --git a/ui/pages/GasTracker.tsx b/ui/pages/GasTracker.tsx index ecb4928257..b3212adce2 100644 --- a/ui/pages/GasTracker.tsx +++ b/ui/pages/GasTracker.tsx @@ -36,7 +36,8 @@ const GasTracker = () => { rowGap={ 1 } flexDir={{ base: 'column', lg: 'row' }} > - { data?.network_utilization_percentage && } + { typeof data?.network_utilization_percentage === 'number' && + } { data?.gas_price_updated_at && ( Last updated diff --git a/ui/pages/Marketplace.pw.tsx b/ui/pages/Marketplace.pw.tsx index bbf0605f57..004b1c6f95 100644 --- a/ui/pages/Marketplace.pw.tsx +++ b/ui/pages/Marketplace.pw.tsx @@ -46,14 +46,11 @@ test('with banner +@dark-mode', async({ render, mockEnvs, mockConfigResponse }) 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'; await mockEnvs([ [ '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)); const component = await render(); await component.getByText('Apps scores').click(); @@ -95,14 +92,11 @@ test.describe('mobile', () => { 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'; await mockEnvs([ [ '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)); const component = await render(); await component.getByText('Apps scores').click(); diff --git a/ui/pages/Marketplace.tsx b/ui/pages/Marketplace.tsx index b310d89a5a..11c8776db8 100644 --- a/ui/pages/Marketplace.tsx +++ b/ui/pages/Marketplace.tsx @@ -7,7 +7,6 @@ import type { TabItem } from 'ui/shared/Tabs/types'; import config from 'configs/app'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; -import useFeatureValue from 'lib/growthbook/useFeatureValue'; import useIsMobile from 'lib/hooks/useIsMobile'; import Banner from 'ui/marketplace/Banner'; import ContractListModal from 'ui/marketplace/ContractListModal'; @@ -74,7 +73,6 @@ const Marketplace = () => { } = useMarketplace(); const isMobile = useIsMobile(); - const { value: isExperiment } = useFeatureValue('security_score_exp', false); const categoryTabs = React.useMemo(() => { const tabs: Array = categories.map(category => ({ @@ -189,7 +187,7 @@ const Marketplace = () => { - { (feature.securityReportsUrl && isExperiment) && ( + { feature.securityReportsUrl && ( onChange={ onDisplayTypeChange } @@ -226,12 +224,12 @@ const Marketplace = () => { onChange={ onSearchInputChange } placeholder="Find app by name or keyword..." isLoading={ isPlaceholderData } - size={ (feature.securityReportsUrl && isExperiment) ? 'xs' : 'sm' } + size={ feature.securityReportsUrl ? 'xs' : 'sm' } flex="1" /> - { (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl && isExperiment) ? ( + { (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl) ? ( { +test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { // test on mobile is flaky // 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 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({ - 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( - - - , - ); + const component = await render(); await expect(component).toHaveScreenshot({ timeout: 10_000 }); }); diff --git a/ui/pages/OptimisticL2OutputRoots.pw.tsx b/ui/pages/OptimisticL2OutputRoots.pw.tsx index fefffc20e6..bf9878b0f5 100644 --- a/ui/pages/OptimisticL2OutputRoots.pw.tsx +++ b/ui/pages/OptimisticL2OutputRoots.pw.tsx @@ -1,48 +1,20 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import { outputRootsData } from 'mocks/l2outputRoots/outputRoots'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import OptimisticL2OutputRoots from './OptimisticL2OutputRoots'; -const test = base.extend({ - // 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('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { // test on mobile is flaky // 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 test.slow(); - - await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ - status: 200, - body: '', - })); - - 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( - - - , - ); - + await mockEnvs(ENVS_MAP.optimisticRollup); + await mockTextAd(); + await mockApiResponse('optimistic_l2_output_roots', outputRootsData); + await mockApiResponse('optimistic_l2_output_roots_count', 9927); + const component = await render(); await expect(component).toHaveScreenshot({ timeout: 10_000 }); }); diff --git a/ui/pages/OptimisticL2TxnBatches.pw.tsx b/ui/pages/OptimisticL2TxnBatches.pw.tsx index b781c6db85..f98922fdc6 100644 --- a/ui/pages/OptimisticL2TxnBatches.pw.tsx +++ b/ui/pages/OptimisticL2TxnBatches.pw.tsx @@ -1,48 +1,20 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import { txnBatchesData } from 'mocks/l2txnBatches/txnBatches'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import OptimisticL2TxnBatches from './OptimisticL2TxnBatches'; -const test = base.extend({ - // 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('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => { // test on mobile is flaky // 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 test.slow(); - - await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ - status: 200, - body: '', - })); - - 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( - - - , - ); - + await mockTextAd(); + await mockEnvs(ENVS_MAP.optimisticRollup); + await mockApiResponse('optimistic_l2_txn_batches', txnBatchesData); + await mockApiResponse('optimistic_l2_txn_batches_count', 1235016); + const component = await render(); await expect(component).toHaveScreenshot({ timeout: 10_000 }); }); diff --git a/ui/pages/OptimisticL2Withdrawals.pw.tsx b/ui/pages/OptimisticL2Withdrawals.pw.tsx index b30611cc93..98cbeea711 100644 --- a/ui/pages/OptimisticL2Withdrawals.pw.tsx +++ b/ui/pages/OptimisticL2Withdrawals.pw.tsx @@ -1,48 +1,20 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import { data as withdrawalsData } from 'mocks/l2withdrawals/withdrawals'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import OptimisticL2Withdrawals from './OptimisticL2Withdrawals'; -const test = base.extend({ - // 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('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => { // test on mobile is flaky // 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 test.slow(); - - 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_COUNT_API_URL, (route) => route.fulfill({ - status: 200, - body: '397', - })); - - const component = await mount( - - - , - ); - + await mockTextAd(); + await mockEnvs(ENVS_MAP.optimisticRollup); + await mockApiResponse('optimistic_l2_withdrawals', withdrawalsData); + await mockApiResponse('optimistic_l2_withdrawals_count', 397); + const component = await render(); await expect(component).toHaveScreenshot({ timeout: 10_000 }); }); diff --git a/ui/pages/Token.tsx b/ui/pages/Token.tsx index fe1b03ffda..0ca0d57dde 100644 --- a/ui/pages/Token.tsx +++ b/ui/pages/Token.tsx @@ -246,7 +246,7 @@ const TokenPageContent = () => { <> - + diff --git a/ui/pages/Transaction.tsx b/ui/pages/Transaction.tsx index 7b39b74667..65dea57d3f 100644 --- a/ui/pages/Transaction.tsx +++ b/ui/pages/Transaction.tsx @@ -9,7 +9,7 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import getQueryParamString from 'lib/router/getQueryParamString'; import { publicClient } from 'lib/web3/client'; 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 RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; @@ -76,7 +76,7 @@ const TransactionPageContent = () => { const tags = ( ); diff --git a/ui/pages/UserOp.pw.tsx b/ui/pages/UserOp.pw.tsx index 8b7b114c8c..dabf5c928e 100644 --- a/ui/pages/UserOp.pw.tsx +++ b/ui/pages/UserOp.pw.tsx @@ -1,73 +1,36 @@ -import { test as base, expect, devices } from '@playwright/experimental-ct-react'; import React from 'react'; import { userOpData } from 'mocks/userOps/userOp'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect, devices } from 'playwright/lib'; import UserOp from './UserOp'; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.userOps) as any, -}); - -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 hooksConfig = { + router: { + query: { hash: userOpData.hash }, + isReady: true, + }, +}; - const component = await mount( - - - , - { hooksConfig: { - router: { - query: { hash: userOpData.hash }, - isReady: true, - }, - } }, - ); +test.beforeEach(async({ mockEnvs }) => { + await mockEnvs(ENVS_MAP.userOps); +}); +test('base view', async({ render, mockTextAd, mockApiResponse }) => { + await mockTextAd(); + await mockApiResponse('user_op', userOpData, { pathParams: { hash: userOpData.hash } }); + const component = await render(, { hooksConfig }); await expect(component).toHaveScreenshot(); }); test.describe('mobile', () => { test.use({ viewport: devices['iPhone 13 Pro'].viewport }); - 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( - - - , - { 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(, { hooksConfig }); await expect(component).toHaveScreenshot(); }); }); diff --git a/ui/pages/UserOps.pw.tsx b/ui/pages/UserOps.pw.tsx index 4d0cb8fe1d..2a241f1d52 100644 --- a/ui/pages/UserOps.pw.tsx +++ b/ui/pages/UserOps.pw.tsx @@ -1,40 +1,16 @@ import { Box } from '@chakra-ui/react'; -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import { userOpsData } from 'mocks/userOps/userOps'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import UserOps from './UserOps'; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.userOps) as any, -}); - -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( - - - - - , - ); - +test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { + await mockEnvs(ENVS_MAP.userOps); + await mockTextAd(); + await mockApiResponse('user_ops', userOpsData); + const component = await render( ); await expect(component).toHaveScreenshot(); }); diff --git a/ui/pages/ZkEvmL2TxnBatch.pw.tsx b/ui/pages/ZkEvmL2TxnBatch.pw.tsx index ed78df63a1..9c717759b5 100644 --- a/ui/pages/ZkEvmL2TxnBatch.pw.tsx +++ b/ui/pages/ZkEvmL2TxnBatch.pw.tsx @@ -1,70 +1,35 @@ -import { test as base, expect, devices } from '@playwright/experimental-ct-react'; import React from 'react'; import { txnBatchData } from 'mocks/zkEvm/txnBatches'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect, devices } from 'playwright/lib'; import ZkEvmL2TxnBatch from './ZkEvmL2TxnBatch'; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any, -}); - +const batchNumber = '5'; const hooksConfig = { 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(); - await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ - status: 200, - body: '', - })); - - await page.route(BATCH_API_URL, (route) => route.fulfill({ - status: 200, - body: JSON.stringify(txnBatchData), - })); - - const component = await mount( - - - , - { hooksConfig }, - ); - + const component = await render(, { hooksConfig }); await expect(component).toHaveScreenshot(); }); test.describe('mobile', () => { test.use({ viewport: devices['iPhone 13 Pro'].viewport }); - test('base view', async({ mount, page }) => { + test('base view', async({ render }) => { test.slow(); - await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ - status: 200, - body: '', - })); - - await page.route(BATCH_API_URL, (route) => route.fulfill({ - status: 200, - body: JSON.stringify(txnBatchData), - })); - - const component = await mount( - - - , - { hooksConfig }, - ); - + const component = await render(, { hooksConfig }); await expect(component).toHaveScreenshot(); }); }); diff --git a/ui/pages/ZkEvmL2TxnBatches.pw.tsx b/ui/pages/ZkEvmL2TxnBatches.pw.tsx index 1bc3e6ed6a..61a3af145d 100644 --- a/ui/pages/ZkEvmL2TxnBatches.pw.tsx +++ b/ui/pages/ZkEvmL2TxnBatches.pw.tsx @@ -1,43 +1,16 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import { txnBatchesData } from 'mocks/zkEvm/txnBatches'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import ZkEvmL2TxnBatches from './ZkEvmL2TxnBatches'; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any, -}); - -const BATCHES_API_URL = buildApiUrl('zkevm_l2_txn_batches'); -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( - - - , - ); - +test('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => { + await mockEnvs(ENVS_MAP.zkEvmRollup); + await mockTextAd(); + await mockApiResponse('zkevm_l2_txn_batches', txnBatchesData); + await mockApiResponse('zkevm_l2_txn_batches_count', 9927); + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/pages/ZkSyncL2TxnBatch.pw.tsx b/ui/pages/ZkSyncL2TxnBatch.pw.tsx index 055a678cf3..b7bc910f19 100644 --- a/ui/pages/ZkSyncL2TxnBatch.pw.tsx +++ b/ui/pages/ZkSyncL2TxnBatch.pw.tsx @@ -1,60 +1,33 @@ -import { test as base, expect, devices } from '@playwright/experimental-ct-react'; import React from 'react'; import * as zkSyncTxnBatchMock from 'mocks/zkSync/zkSyncTxnBatch'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect, devices } from 'playwright/lib'; import ZkSyncL2TxnBatch from './ZkSyncL2TxnBatch'; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.zkSyncRollup) as any, -}); - +const batchNumber = String(zkSyncTxnBatchMock.base.number); const hooksConfig = { router: { - query: { number: String(zkSyncTxnBatchMock.base.number) }, + query: { number: batchNumber }, }, }; -test.beforeEach(async({ page }) => { - await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ - status: 200, - body: '', - })); - - await page.route(BATCH_API_URL, (route) => route.fulfill({ - status: 200, - body: JSON.stringify(zkSyncTxnBatchMock.base), - })); +test.beforeEach(async({ mockTextAd, mockApiResponse, mockEnvs }) => { + await mockEnvs(ENVS_MAP.zkSyncRollup); + await mockTextAd(); + await mockApiResponse('zksync_l2_txn_batch', zkSyncTxnBatchMock.base, { pathParams: { number: batchNumber } }); }); -const BATCH_API_URL = buildApiUrl('zksync_l2_txn_batch', { number: String(zkSyncTxnBatchMock.base.number) }); - -test('base view', async({ mount }) => { - const component = await mount( - - - , - { hooksConfig }, - ); - +test('base view', async({ render }) => { + const component = await render(, { hooksConfig }); await expect(component).toHaveScreenshot(); }); test.describe('mobile', () => { test.use({ viewport: devices['iPhone 13 Pro'].viewport }); - test('base view', async({ mount }) => { - const component = await mount( - - - , - { hooksConfig }, - ); - + test('base view', async({ render }) => { + const component = await render(, { hooksConfig }); await expect(component).toHaveScreenshot(); }); }); diff --git a/ui/pages/ZkSyncL2TxnBatches.pw.tsx b/ui/pages/ZkSyncL2TxnBatches.pw.tsx index 8b0bf139a0..9530e0a569 100644 --- a/ui/pages/ZkSyncL2TxnBatches.pw.tsx +++ b/ui/pages/ZkSyncL2TxnBatches.pw.tsx @@ -1,44 +1,18 @@ -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import * as zkSyncTxnBatchesMock from 'mocks/zkSync/zkSyncTxnBatches'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import ZkSyncL2TxnBatches from './ZkSyncL2TxnBatches'; -const test = base.extend({ - // 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('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { test.slow(); - 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(zkSyncTxnBatchesMock.baseResponse), - })); - - await page.route(BATCHES_COUNTERS_API_URL, (route) => route.fulfill({ - status: 200, - body: '9927', - })); - - const component = await mount( - - - , - ); + await mockEnvs(ENVS_MAP.zkSyncRollup); + await mockTextAd(); + await mockApiResponse('zksync_l2_txn_batches', zkSyncTxnBatchesMock.baseResponse); + await mockApiResponse('zksync_l2_txn_batches_count', 9927); + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_default_base-view-mobile-1.png index 601ac6bbb7..f3eb1c52ce 100644 Binary files a/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_mobile_base-view-mobile-1.png index add6a71f0d..f8afc166ef 100644 Binary files a/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/BeaconChainWithdrawals.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_default_base-view-mobile-1.png index cc84b3b632..91200b6ada 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_mobile_base-view-mobile-1.png index 14787cec19..20be8d7d75 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2Deposits.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_default_base-view-mobile-1.png index 3c0ddb505c..004a03d2aa 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_mobile_base-view-mobile-1.png index 8724dfff72..b04c166403 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2OutputRoots.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_default_base-view-mobile-1.png index 10bed40ba0..318dd12bfc 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png index 9175326615..16909272ec 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_default_base-view-mobile-1.png index a9b858056c..2f959502d4 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_mobile_base-view-mobile-1.png index f29201642a..2744ff898f 100644 Binary files a/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/OptimisticL2Withdrawals.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png b/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png index f04e9f2426..6a3328be1c 100644 Binary files a/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png and b/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png differ diff --git a/ui/pages/__screenshots__/UserOp.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/UserOp.pw.tsx_default_base-view-1.png index 876c1c9b52..25ac4e7eb3 100644 Binary files a/ui/pages/__screenshots__/UserOp.pw.tsx_default_base-view-1.png and b/ui/pages/__screenshots__/UserOp.pw.tsx_default_base-view-1.png differ diff --git a/ui/pages/__screenshots__/UserOp.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/UserOp.pw.tsx_default_mobile-base-view-1.png index 172f7d7acc..3be7bed259 100644 Binary files a/ui/pages/__screenshots__/UserOp.pw.tsx_default_mobile-base-view-1.png and b/ui/pages/__screenshots__/UserOp.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/pages/__screenshots__/UserOps.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/UserOps.pw.tsx_default_base-view-mobile-1.png index 50ac818186..97b8d3e2aa 100644 Binary files a/ui/pages/__screenshots__/UserOps.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/UserOps.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/UserOps.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/UserOps.pw.tsx_mobile_base-view-mobile-1.png index 1c58075243..757be59890 100644 Binary files a/ui/pages/__screenshots__/UserOps.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/UserOps.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png index 342942ece0..c816635460 100644 Binary files a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png and b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png differ diff --git a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png index d978d60f7a..f61caf8af7 100644 Binary files a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png and b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_default_base-view-mobile-1.png index 3766679027..65204d5399 100644 Binary files a/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png index 0e038dec5d..43d3d24f71 100644 Binary files a/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/ZkEvmL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_base-view-1.png index 48f02b27d4..7c1cd303a7 100644 Binary files a/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_base-view-1.png and b/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_base-view-1.png differ diff --git a/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_mobile-base-view-1.png index 6bd9369d23..3fdca929ba 100644 Binary files a/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_mobile-base-view-1.png and b/ui/pages/__screenshots__/ZkSyncL2TxnBatch.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_default_base-view-mobile-1.png index 6d14c9d8d0..ca59213667 100644 Binary files a/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_default_base-view-mobile-1.png and b/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png index a12fa458f1..486be5b14d 100644 Binary files a/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png and b/ui/pages/__screenshots__/ZkSyncL2TxnBatches.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/shared/CopyToClipboard.tsx b/ui/shared/CopyToClipboard.tsx index 000678cd90..c3b67545de 100644 --- a/ui/shared/CopyToClipboard.tsx +++ b/ui/shared/CopyToClipboard.tsx @@ -7,9 +7,10 @@ export interface Props { text: string; className?: string; 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 [ copied, setCopied ] = useState(false); // 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) => { } }, [ hasCopied ]); + const handleClick = React.useCallback((event: React.MouseEvent) => { + onCopy(); + onClick?.(event); + }, [ onClick, onCopy ]); + if (isLoading) { return ; } @@ -39,7 +45,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => { variant="simple" display="inline-block" flexShrink={ 0 } - onClick={ onCopy } + onClick={ handleClick } className={ className } onMouseEnter={ onOpen } onMouseLeave={ onClose } diff --git a/ui/shared/EmptySearchResult.tsx b/ui/shared/EmptySearchResult.tsx index 92c27fbecc..93433c17c7 100644 --- a/ui/shared/EmptySearchResult.tsx +++ b/ui/shared/EmptySearchResult.tsx @@ -16,23 +16,21 @@ const EmptySearchResult = ({ text }: Props) => { display="flex" flexDirection="column" alignItems="center" + justifyContent="center" + mt="50px" > - + - + No results - + { text } diff --git a/ui/shared/EntityTags.tsx b/ui/shared/EntityTags.tsx deleted file mode 100644 index 40245f7984..0000000000 --- a/ui/shared/EntityTags.tsx +++ /dev/null @@ -1,124 +0,0 @@ -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; - tagsAfter?: Array; - contentAfter?: React.ReactNode; -} - -const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoading, contentAfter }: Props) => { - const isMobile = useIsMobile(); - const { isOpen, onToggle, onClose } = useDisclosure(); - - const tags: Array = [ - ...tagsBefore, - ...(data?.private_tags || []), - ...(data?.public_tags || []), - ...(data?.watchlist_names || []), - ...tagsAfter, - ] - .filter(Boolean); - - const metaSuitesPlaceholder = config.features.metasuites.isEnabled ? - : - null; - - if (tags.length === 0 && !contentAfter) { - return metaSuitesPlaceholder; - } - - const content = (() => { - if (isMobile && tags.length > 2) { - return ( - <> - { - tags - .slice(0, 2) - .map((tag) => ( - - { tag.display_name } - - )) - } - { metaSuitesPlaceholder } - - - +{ tags.length - 1 } - - - - - { - tags - .slice(2) - .map((tag) => ( - - { tag.display_name } - - )) - } - - - - - - ); - } - - return ( - <> - { tags.map((tag) => ( - - { tag.display_name } - - )) } - { metaSuitesPlaceholder } - - ); - })(); - - return ( - - { content } - { contentAfter } - - ); -}; - -export default React.memo(chakra(EntityTags)); diff --git a/ui/shared/EntityTags/EntityTag.pw.tsx b/ui/shared/EntityTags/EntityTag.pw.tsx new file mode 100644 index 0000000000..299c48cf31 --- /dev/null +++ b/ui/shared/EntityTags/EntityTag.pw.tsx @@ -0,0 +1,37 @@ +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(); + await expect(component).toHaveScreenshot(); +}); + +test('generic tag +@dark-mode', async({ render }) => { + const component = await render(); + await expect(component).toHaveScreenshot(); +}); + +test('protocol tag +@dark-mode', async({ render }) => { + const component = await render(); + await expect(component).toHaveScreenshot(); +}); + +test('tag with link and long name +@dark-mode', async({ render }) => { + const component = await render(); + 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(); + await component.getByText('BlockscoutHeroes').hover(); + await page.getByText('Blockscout team member').waitFor({ state: 'visible' }); + await expect(page).toHaveScreenshot(); +}); diff --git a/ui/shared/EntityTags/EntityTag.tsx b/ui/shared/EntityTags/EntityTag.tsx new file mode 100644 index 0000000000..5a83b3d4b4 --- /dev/null +++ b/ui/shared/EntityTags/EntityTag.tsx @@ -0,0 +1,51 @@ +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 ; + } + + // 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 ( + + + + { data.tagType === 'name' && } + { (data.tagType === 'protocol' || data.tagType === 'generic') && # } + + + + + ); +}; + +export default React.memo(EntityTag); diff --git a/ui/shared/EntityTags/EntityTagLink.tsx b/ui/shared/EntityTags/EntityTagLink.tsx new file mode 100644 index 0000000000..00f6b23115 --- /dev/null +++ b/ui/shared/EntityTags/EntityTagLink.tsx @@ -0,0 +1,70 @@ +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 ( + // + // { children } + // + // ); + // } + // } + + if (data.meta?.tagUrl) { + return ( + + { children } + + ); + } + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{ children }; +}; + +export default React.memo(EntityTagLink); diff --git a/ui/shared/EntityTags/EntityTagPopover.tsx b/ui/shared/EntityTags/EntityTagPopover.tsx new file mode 100644 index 0000000000..1451918ce8 --- /dev/null +++ b/ui/shared/EntityTags/EntityTagPopover.tsx @@ -0,0 +1,61 @@ +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 ( + + + { children } + + + + + + { (data.meta?.tooltipIcon || data.meta?.tooltipTitle) && ( + + { data.meta?.tooltipIcon && { } + { data.meta?.tooltipTitle && { data.meta.tooltipTitle } } + + ) } + { data.meta?.tooltipDescription && { data.meta.tooltipDescription } } + { link && { link.domain } } + + + + + ); +}; + +export default React.memo(EntityTagPopover); diff --git a/ui/shared/EntityTags/EntityTags.tsx b/ui/shared/EntityTags/EntityTags.tsx new file mode 100644 index 0000000000..26698b8be2 --- /dev/null +++ b/ui/shared/EntityTags/EntityTags.tsx @@ -0,0 +1,69 @@ +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; + isLoading?: boolean; +} + +const EntityTags = ({ tags, className, isLoading }: Props) => { + const isMobile = useIsMobile(); + const visibleNum = isMobile ? 2 : 3; + + const metaSuitesPlaceholder = config.features.metasuites.isEnabled ? + : + null; + + if (tags.length === 0) { + return metaSuitesPlaceholder; + } + + const content = (() => { + if (tags.length > visibleNum) { + return ( + <> + { tags.slice(0, visibleNum).map((tag) => ) } + { metaSuitesPlaceholder } + + + + +{ tags.length - visibleNum } + + + + + + { tags.slice(visibleNum).map((tag) => ) } + + + + + + ); + } + + return ( + <> + { tags.map((tag) => ) } + { metaSuitesPlaceholder } + + ); + })(); + + return ( + + { content } + + ); +}; + +export default React.memo(chakra(EntityTags)); diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_custom-name-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_custom-name-tag-dark-mode-1.png new file mode 100644 index 0000000000..932254450b Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_custom-name-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_generic-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_generic-tag-dark-mode-1.png new file mode 100644 index 0000000000..2381600421 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_generic-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_protocol-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_protocol-tag-dark-mode-1.png new file mode 100644 index 0000000000..41836e3717 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_protocol-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-link-and-long-name-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-link-and-long-name-dark-mode-1.png new file mode 100644 index 0000000000..dc42e698ef Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-link-and-long-name-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-tooltip-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-tooltip-dark-mode-1.png new file mode 100644 index 0000000000..f86bb76d63 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_dark-color-mode_tag-with-tooltip-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_custom-name-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_custom-name-tag-dark-mode-1.png new file mode 100644 index 0000000000..2fa5802c36 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_custom-name-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_generic-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_generic-tag-dark-mode-1.png new file mode 100644 index 0000000000..9e12c057a6 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_generic-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_protocol-tag-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_protocol-tag-dark-mode-1.png new file mode 100644 index 0000000000..c5de547a2d Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_protocol-tag-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-link-and-long-name-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-link-and-long-name-dark-mode-1.png new file mode 100644 index 0000000000..ec1924e091 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-link-and-long-name-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-tooltip-dark-mode-1.png b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-tooltip-dark-mode-1.png new file mode 100644 index 0000000000..244342f1c3 Binary files /dev/null and b/ui/shared/EntityTags/__screenshots__/EntityTag.pw.tsx_default_tag-with-tooltip-dark-mode-1.png differ diff --git a/ui/shared/EntityTags/formatUserTags.ts b/ui/shared/EntityTags/formatUserTags.ts new file mode 100644 index 0000000000..f644647ae0 --- /dev/null +++ b/ui/shared/EntityTags/formatUserTags.ts @@ -0,0 +1,9 @@ +import type { EntityTag } from './types'; +import type { UserTags } from 'types/api/addressParams'; + +export default function formatUserTags(data: UserTags | undefined): Array { + 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 })), + ]; +} diff --git a/ui/shared/EntityTags/sortEntityTags.ts b/ui/shared/EntityTags/sortEntityTags.ts new file mode 100644 index 0000000000..8227829f5d --- /dev/null +++ b/ui/shared/EntityTags/sortEntityTags.ts @@ -0,0 +1,13 @@ +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; +} diff --git a/ui/shared/EntityTags/types.ts b/ui/shared/EntityTags/types.ts new file mode 100644 index 0000000000..47a444f0ad --- /dev/null +++ b/ui/shared/EntityTags/types.ts @@ -0,0 +1,9 @@ +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 { + tagType: EntityTagType; + meta?: AddressMetadataTagFormatted['meta']; +} diff --git a/ui/shared/LinkExternal.tsx b/ui/shared/LinkExternal.tsx index cae2427170..31aaa7593d 100644 --- a/ui/shared/LinkExternal.tsx +++ b/ui/shared/LinkExternal.tsx @@ -1,4 +1,4 @@ -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 React from 'react'; @@ -10,9 +10,11 @@ interface Props { children: React.ReactNode; isLoading?: boolean; variant?: 'subtle'; + iconColor?: LinkProps['color']; + onClick?: LinkProps['onClick']; } -const LinkExternal = ({ href, children, className, isLoading, variant }: Props) => { +const LinkExternal = ({ href, children, className, isLoading, variant, iconColor, onClick }: Props) => { const subtleLinkBg = useColorModeValue('gray.100', 'gray.700'); const styleProps: ChakraProps = (() => { @@ -57,9 +59,9 @@ const LinkExternal = ({ href, children, className, isLoading, variant }: Props) } return ( - + { children } - + ); }; diff --git a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png index 1284ee5f7b..6e08e3c204 100644 Binary files a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png and b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_default_with-long-name-and-many-tags-mobile-1.png differ diff --git a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png index d7fd28ebf2..c1904cbeca 100644 Binary files a/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png and b/ui/shared/Page/__screenshots__/PageTitle.pw.tsx_mobile_with-long-name-and-many-tags-mobile-1.png differ diff --git a/ui/shared/Page/specs/DefaultView.tsx b/ui/shared/Page/specs/DefaultView.tsx index 0c924f55e0..0c117d1411 100644 --- a/ui/shared/Page/specs/DefaultView.tsx +++ b/ui/shared/Page/specs/DefaultView.tsx @@ -5,7 +5,7 @@ import type { TokenInfo } from 'types/api/token'; import * as addressMock from 'mocks/address/address'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; 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 NetworkExplorers from 'ui/shared/NetworkExplorers'; @@ -34,8 +34,8 @@ const DefaultView = () => { <> diff --git a/ui/shared/Page/specs/LongNameAndManyTags.tsx b/ui/shared/Page/specs/LongNameAndManyTags.tsx index ae8e190cda..4c690737b9 100644 --- a/ui/shared/Page/specs/LongNameAndManyTags.tsx +++ b/ui/shared/Page/specs/LongNameAndManyTags.tsx @@ -5,7 +5,8 @@ import type { TokenInfo } from 'types/api/token'; import { publicTag, privateTag, watchlistName } from 'mocks/address/tag'; 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 NetworkExplorers from 'ui/shared/NetworkExplorers'; @@ -29,21 +30,19 @@ const LongNameAndManyTags = () => { <> } flexGrow={ 1 } /> + ); diff --git a/ui/shared/Tabs/TabsWithScroll.tsx b/ui/shared/Tabs/TabsWithScroll.tsx index 967b0f9740..efbde5723d 100644 --- a/ui/shared/Tabs/TabsWithScroll.tsx +++ b/ui/shared/Tabs/TabsWithScroll.tsx @@ -52,8 +52,11 @@ const TabsWithScroll = ({ }, [ tabs ]); const handleTabChange = React.useCallback((index: number) => { + if (isLoading) { + return; + } onTabChange ? onTabChange(index) : setActiveTabIndex(index); - }, [ onTabChange ]); + }, [ isLoading, onTabChange ]); useEffect(() => { if (defaultTabIndex !== undefined) { diff --git a/ui/shared/TruncatedTextTooltip.tsx b/ui/shared/TruncatedTextTooltip.tsx index 1f24937d7d..a929f1b63a 100644 --- a/ui/shared/TruncatedTextTooltip.tsx +++ b/ui/shared/TruncatedTextTooltip.tsx @@ -1,3 +1,4 @@ +import type { PlacementWithLogical } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react'; import debounce from 'lodash/debounce'; import React from 'react'; @@ -8,9 +9,10 @@ import { BODY_TYPEFACE } from 'theme/foundations/typography'; interface Props { children: React.ReactNode; label: string; + placement?: PlacementWithLogical; } -const TruncatedTextTooltip = ({ children, label }: Props) => { +const TruncatedTextTooltip = ({ children, label, placement }: Props) => { const childRef = React.useRef(null); const [ isTruncated, setTruncated ] = React.useState(false); @@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => { ); if (isTruncated) { - return { modifiedChildren }; + return { modifiedChildren }; } return modifiedChildren; diff --git a/ui/shared/TruncatedValue.tsx b/ui/shared/TruncatedValue.tsx index 626039c5c7..f953477b08 100644 --- a/ui/shared/TruncatedValue.tsx +++ b/ui/shared/TruncatedValue.tsx @@ -1,3 +1,4 @@ +import type { PlacementWithLogical } from '@chakra-ui/react'; import { Skeleton, chakra } from '@chakra-ui/react'; import React from 'react'; @@ -7,11 +8,12 @@ interface Props { className?: string; isLoading?: boolean; value: string; + tooltipPlacement?: PlacementWithLogical; } -const TruncatedValue = ({ className, isLoading, value }: Props) => { +const TruncatedValue = ({ className, isLoading, value, tooltipPlacement }: Props) => { return ( - + import('getit-sdk').then(module => module.GetitAdPlugin), { ssr: false }); const GETIT_API_KEY = 'ZmGXVvwYUAW4yXL8RzWQHNKmpSyQmt3TDXsXUxqFqXPdoaiSSFyca3BOyunDcWdyOwTkX3UVVQel28qbjoOoWPxYVpPdNzbUNkAHyFyJX7Lk9TVcPDZKTQmwHlSMzO3a'; -const GetitBannerContent = ({ address, className }: { address?: string; className?: string }) => { +const GetitBanner = ({ className }: { className?: string }) => { const isMobile = Boolean(useIsMobile()); + const { address } = useAccount(); return ( @@ -27,22 +26,4 @@ const GetitBannerContent = ({ address, className }: { address?: string; classNam ); }; -const GetitBannerWithWalletAddress = ({ className }: { className?: string }) => { - const { address } = useAccount(); - - return ; -}; - -const GetitBanner = ({ className }: { className?: string }) => { - const fallback = React.useCallback(() => { - return ; - }, [ className ]); - - return ( - - - - ); -}; - export default chakra(GetitBanner); diff --git a/ui/shared/ad/HypeBanner.tsx b/ui/shared/ad/HypeBanner.tsx index 7934ddc867..adcdeae993 100644 --- a/ui/shared/ad/HypeBanner.tsx +++ b/ui/shared/ad/HypeBanner.tsx @@ -2,15 +2,22 @@ import { Flex, chakra } from '@chakra-ui/react'; import { Banner, setWalletAddresses } from '@hypelab/sdk-react'; import Script from 'next/script'; import React from 'react'; -import { useAccount } from 'wagmi'; -import Web3ModalProvider from '../Web3ModalProvider'; +import useAccount from 'lib/web3/useAccount'; + import { hypeInit } from './hypeBannerScript'; const DESKTOP_BANNER_SLUG = 'b1559fc3e7'; 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 ( <> @@ -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 ; -}; - -const HypeBanner = ({ className }: { className?: string }) => { - - const fallback = React.useCallback(() => { - return ; - }, [ className ]); - - return ( - - - - ); -}; - export default chakra(HypeBanner); diff --git a/ui/shared/chakra/Tag.tsx b/ui/shared/chakra/Tag.tsx index 08d2d6f130..f8e0cb762f 100644 --- a/ui/shared/chakra/Tag.tsx +++ b/ui/shared/chakra/Tag.tsx @@ -4,7 +4,7 @@ import React from 'react'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; -interface Props extends TagProps { +export interface Props extends TagProps { isLoading?: boolean; } diff --git a/ui/shared/entities/address/AddressEntity.pw.tsx b/ui/shared/entities/address/AddressEntity.pw.tsx index 91ffb6d093..e62685c8c2 100644 --- a/ui/shared/entities/address/AddressEntity.pw.tsx +++ b/ui/shared/entities/address/AddressEntity.pw.tsx @@ -97,6 +97,18 @@ test('with ENS', async({ mount }) => { await expect(component).toHaveScreenshot(); }); +test('with name tag', async({ mount }) => { + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); + test('external link', async({ mount }) => { const component = await mount( diff --git a/ui/shared/entities/address/AddressEntity.tsx b/ui/shared/entities/address/AddressEntity.tsx index 08d043ffe4..a5b2bd95d9 100644 --- a/ui/shared/entities/address/AddressEntity.tsx +++ b/ui/shared/entities/address/AddressEntity.tsx @@ -100,11 +100,13 @@ const Icon = (props: IconProps) => { type ContentProps = Omit & Pick; const Content = chakra((props: ContentProps) => { - if (props.address.name || props.address.ens_domain_name) { - const text = props.address.ens_domain_name || props.address.name; + const nameTag = props.address.metadata?.tags.find(tag => tag.tagType === 'name')?.name; + const nameText = nameTag || props.address.ens_domain_name || props.address.name; + + if (nameText) { const label = ( - { text } + { nameText } { props.address.hash } ); @@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => { return ( - { text } + { nameText } ); @@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => { const Container = EntityBase.Container; export interface EntityProps extends EntityBase.EntityBaseProps { - address: Pick; + address: Pick; isSafeAddress?: boolean; } diff --git a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_with-name-tag-1.png b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_with-name-tag-1.png new file mode 100644 index 0000000000..b3b745e458 Binary files /dev/null and b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_with-name-tag-1.png differ diff --git a/ui/shared/layout/__screenshots__/Layout.pw.tsx_mobile_base-view-mobile-1.png b/ui/shared/layout/__screenshots__/Layout.pw.tsx_mobile_base-view-mobile-1.png index 137ece3ca4..244782b497 100644 Binary files a/ui/shared/layout/__screenshots__/Layout.pw.tsx_mobile_base-view-mobile-1.png and b/ui/shared/layout/__screenshots__/Layout.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/shared/layout/__screenshots__/LayoutError.pw.tsx_mobile_base-view-mobile-1.png b/ui/shared/layout/__screenshots__/LayoutError.pw.tsx_mobile_base-view-mobile-1.png index cf60a635ee..14586edfaf 100644 Binary files a/ui/shared/layout/__screenshots__/LayoutError.pw.tsx_mobile_base-view-mobile-1.png and b/ui/shared/layout/__screenshots__/LayoutError.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/shared/layout/__screenshots__/LayoutHome.pw.tsx_mobile_base-view-mobile-1.png b/ui/shared/layout/__screenshots__/LayoutHome.pw.tsx_mobile_base-view-mobile-1.png index 769ba157b3..eb51b01795 100644 Binary files a/ui/shared/layout/__screenshots__/LayoutHome.pw.tsx_mobile_base-view-mobile-1.png and b/ui/shared/layout/__screenshots__/LayoutHome.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/shared/logs/LogDecodedInputDataHeader.tsx b/ui/shared/logs/LogDecodedInputDataHeader.tsx index 653382b18b..ec1680cce9 100644 --- a/ui/shared/logs/LogDecodedInputDataHeader.tsx +++ b/ui/shared/logs/LogDecodedInputDataHeader.tsx @@ -1,13 +1,15 @@ import { Divider, Flex, Skeleton, VStack } from '@chakra-ui/react'; import React from 'react'; +import Tag from 'ui/shared/chakra/Tag'; + interface Props { methodId: string; methodCall: string; 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 ( { label } - { text } + { children } ); }; @@ -32,8 +34,12 @@ const LogDecodedInputDataHeader = ({ methodId, methodCall, isLoading }: Props) = fontSize="sm" lineHeight={ 5 } > - - + + { methodId } + + + { methodCall } + ); }; diff --git a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_dark-color-mode_with-indexed-fields-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_dark-color-mode_with-indexed-fields-mobile-dark-mode-1.png index 98428228b9..5dff66795a 100644 Binary files a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_dark-color-mode_with-indexed-fields-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_dark-color-mode_with-indexed-fields-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_with-indexed-fields-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_with-indexed-fields-mobile-dark-mode-1.png index 0e81521fd8..3c02866fc0 100644 Binary files a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_with-indexed-fields-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_with-indexed-fields-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_without-indexed-fields-mobile-1.png b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_without-indexed-fields-mobile-1.png index 5b5efd2b32..5b743f9c47 100644 Binary files a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_without-indexed-fields-mobile-1.png and b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_default_without-indexed-fields-mobile-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_with-indexed-fields-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_with-indexed-fields-mobile-dark-mode-1.png index 173ef0fc4a..a406c70799 100644 Binary files a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_with-indexed-fields-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_with-indexed-fields-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_without-indexed-fields-mobile-1.png b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_without-indexed-fields-mobile-1.png index 7bde892cd2..6f9b592c45 100644 Binary files a/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_without-indexed-fields-mobile-1.png and b/ui/shared/logs/__screenshots__/LogDecodedInputData.pw.tsx_mobile_without-indexed-fields-mobile-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png index 3902449e9a..29e17c619e 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png index 3063b71f5d..0e1e563737 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png index 3834fa7b4f..7ae98dec50 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png differ diff --git a/ui/snippets/header/HeaderMobile.tsx b/ui/snippets/header/HeaderMobile.tsx index ca543562d2..b1851dd3ba 100644 --- a/ui/snippets/header/HeaderMobile.tsx +++ b/ui/snippets/header/HeaderMobile.tsx @@ -11,10 +11,6 @@ import WalletMenuMobile from 'ui/snippets/walletMenu/WalletMenuMobile'; import Burger from './Burger'; -const LOGO_IMAGE_PROPS = { - margin: '0 auto', -}; - type Props = { hideSearchBar?: boolean; renderSearchBar?: () => React.ReactNode; @@ -45,13 +41,12 @@ const HeaderMobile = ({ hideSearchBar, renderSearchBar }: Props) => { bgColor={ bgColor } width="100%" alignItems="center" - justifyContent="space-between" transitionProperty="box-shadow" transitionDuration="slow" boxShadow={ !inView && scrollDirection === 'down' ? 'md' : 'none' } > - + { config.features.account.isEnabled ? : } { config.features.blockchainInteraction.isEnabled && } diff --git a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png index f3d92fa5d8..523ba2f0af 100644 Binary files a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png and b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png differ diff --git a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png index 526388a465..ea433cbee6 100644 Binary files a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png and b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png differ diff --git a/ui/snippets/navigation/NavLink.tsx b/ui/snippets/navigation/NavLink.tsx index 60f24323d1..1efb4beb58 100644 --- a/ui/snippets/navigation/NavLink.tsx +++ b/ui/snippets/navigation/NavLink.tsx @@ -20,16 +20,17 @@ type Props = { px?: string | number; className?: string; 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 colors = useColors(); const isExpanded = isCollapsed === false; 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 href = isInternalLink ? route(item.nextRoute) : item.url; diff --git a/ui/snippets/networkMenu/NetworkLogo.tsx b/ui/snippets/networkMenu/NetworkLogo.tsx index c89029e58a..11e50c6d95 100644 --- a/ui/snippets/networkMenu/NetworkLogo.tsx +++ b/ui/snippets/networkMenu/NetworkLogo.tsx @@ -1,5 +1,4 @@ -import type { StyleProps } from '@chakra-ui/react'; -import { Box, Image, useColorModeValue, Skeleton } from '@chakra-ui/react'; +import { Box, Image, useColorModeValue, Skeleton, chakra } from '@chakra-ui/react'; import React from 'react'; import { route } from 'nextjs-routes'; @@ -10,10 +9,10 @@ import IconSvg from 'ui/shared/IconSvg'; interface Props { isCollapsed?: boolean; 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 logoColor = useColorModeValue('blue.600', 'white'); @@ -38,12 +37,11 @@ const LogoFallback = ({ isCollapsed, isSmall, imageProps }: { isCollapsed?: bool height="100%" color={ logoColor } 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 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) => { return ( { h="100%" src={ logoSrc } alt={ `${ config.chain.name } network logo` } - fallback={ } + fallback={ } display={{ base: 'block', lg: isCollapsed === false ? 'block' : 'none', xl: isCollapsed ? 'none' : 'block' }} style={ logoStyle } - { ...imageProps } /> { /* small logo */ } { h="100%" src={ iconSrc } alt={ `${ config.chain.name } network logo` } - fallback={ } + fallback={ } display={{ base: 'none', lg: isCollapsed === false ? 'none' : 'block', xl: isCollapsed ? 'block' : 'none' }} style={ iconStyle } - { ...imageProps } /> ); }; -export default React.memo(NetworkLogo); +export default React.memo(chakra(NetworkLogo)); diff --git a/ui/snippets/networkMenu/NetworkMenuContentMobile.tsx b/ui/snippets/networkMenu/NetworkMenuContentMobile.tsx index aa56ba83f6..2e1b5e454f 100644 --- a/ui/snippets/networkMenu/NetworkMenuContentMobile.tsx +++ b/ui/snippets/networkMenu/NetworkMenuContentMobile.tsx @@ -45,10 +45,12 @@ const NetworkMenuContentMobile = ({ items, tabs }: Props) => { ) : ( <> - - + { tabs.length > 1 && ( + + ) } + { items .filter(({ group }) => group === selectedTab) .map((network) => ( diff --git a/ui/snippets/profileMenu/ProfileMenuContent.tsx b/ui/snippets/profileMenu/ProfileMenuContent.tsx index 602fe2ed07..bfbea08981 100644 --- a/ui/snippets/profileMenu/ProfileMenuContent.tsx +++ b/ui/snippets/profileMenu/ProfileMenuContent.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Text, VStack, useColorModeValue } from '@chakra-ui/react'; +import { Box, Button, VStack, chakra } from '@chakra-ui/react'; import React from 'react'; import type { UserInfo } from 'types/api/account'; @@ -18,7 +18,6 @@ type Props = { const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => { const { accountNavItems, profileItem } = useNavItems(); - const primaryTextColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800'); const handleSingOutClick = React.useCallback(() => { mixpanel.logEvent( @@ -32,37 +31,28 @@ const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => { return null; } + const userName = data?.email || data?.nickname || data?.name; + return ( - { (data?.name || data?.nickname) && ( - - Signed in as { data.name || data.nickname } - - ) } - { data?.email && ( - - { data.email } - + Signed in as + { userName } + ) } - + { accountNavItems.map((item) => ( { hasMenu && ( - + diff --git a/ui/snippets/profileMenu/ProfileMenuMobile.tsx b/ui/snippets/profileMenu/ProfileMenuMobile.tsx index 0a1b97eda6..b50367595b 100644 --- a/ui/snippets/profileMenu/ProfileMenuMobile.tsx +++ b/ui/snippets/profileMenu/ProfileMenuMobile.tsx @@ -66,7 +66,7 @@ const ProfileMenuMobile = () => { autoFocus={ false } > - + diff --git a/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png b/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png index 430e738c37..ed2f6a4e4f 100644 Binary files a/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png and b/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png differ diff --git a/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_default_auth-dark-mode-1.png b/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_default_auth-dark-mode-1.png index b4ae10b93c..351421ee0f 100644 Binary files a/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_default_auth-dark-mode-1.png and b/ui/snippets/profileMenu/__screenshots__/ProfileMenuDesktop.pw.tsx_default_auth-dark-mode-1.png differ diff --git a/ui/snippets/profileMenu/__screenshots__/ProfileMenuMobile.pw.tsx_default_auth-base-view-1.png b/ui/snippets/profileMenu/__screenshots__/ProfileMenuMobile.pw.tsx_default_auth-base-view-1.png index b5ad715272..aa5b64facf 100644 Binary files a/ui/snippets/profileMenu/__screenshots__/ProfileMenuMobile.pw.tsx_default_auth-base-view-1.png and b/ui/snippets/profileMenu/__screenshots__/ProfileMenuMobile.pw.tsx_default_auth-base-view-1.png differ diff --git a/ui/snippets/walletMenu/WalletMenuContent.tsx b/ui/snippets/walletMenu/WalletMenuContent.tsx index a3a0c967f6..35f03693d6 100644 --- a/ui/snippets/walletMenu/WalletMenuContent.tsx +++ b/ui/snippets/walletMenu/WalletMenuContent.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Text, Flex } from '@chakra-ui/react'; +import { Box, Button, Text, Flex, IconButton } from '@chakra-ui/react'; import React from 'react'; import * as mixpanel from 'lib/mixpanel/index'; @@ -12,15 +12,24 @@ type Props = { address?: string; disconnect?: () => void; isAutoConnectDisabled?: boolean; + openWeb3Modal: () => void; + closeWalletMenu: () => void; }; -const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props) => { +const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled, openWeb3Modal, closeWalletMenu }: Props) => { const { themedBackgroundOrange } = useMenuButtonColors(); + const [ isModalOpening, setIsModalOpening ] = React.useState(false); const onAddressClick = React.useCallback(() => { mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Address click' }); }, []); + const handleOpenWeb3Modal = React.useCallback(async() => { + setIsModalOpening(true); + await openWeb3Modal(); + setTimeout(closeWalletMenu, 300); + }, [ openWeb3Modal, closeWalletMenu ]); + return ( { isAutoConnectDisabled && ( @@ -60,16 +69,28 @@ const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props > Your wallet is used to interact with apps and contracts in the explorer. - + + + } + variant="simple" + h="20px" + w="20px" + ml={ 1 } + onClick={ handleOpenWeb3Modal } + isLoading={ isModalOpening } + /> + diff --git a/ui/snippets/walletMenu/WalletMenuDesktop.tsx b/ui/snippets/walletMenu/WalletMenuDesktop.tsx index 1778f93b9b..2beaf3c212 100644 --- a/ui/snippets/walletMenu/WalletMenuDesktop.tsx +++ b/ui/snippets/walletMenu/WalletMenuDesktop.tsx @@ -20,7 +20,7 @@ type 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 [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false); const isMobile = useIsMobile(); @@ -82,7 +82,7 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => { variant={ variant } colorScheme="blue" flexShrink={ 0 } - isLoading={ isModalOpening || isModalOpen } + isLoading={ (isModalOpening || isModalOpen) && !isWalletConnected } loadingText="Connect wallet" onClick={ isWalletConnected ? openPopover : connect } fontSize="sm" @@ -102,7 +102,13 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => { { isWalletConnected && ( - + ) } diff --git a/ui/snippets/walletMenu/WalletMenuMobile.tsx b/ui/snippets/walletMenu/WalletMenuMobile.tsx index 0711a107fd..9375facb4f 100644 --- a/ui/snippets/walletMenu/WalletMenuMobile.tsx +++ b/ui/snippets/walletMenu/WalletMenuMobile.tsx @@ -14,7 +14,7 @@ import WalletTooltip from './WalletTooltip'; const WalletMenuMobile = () => { 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 isMobile = useIsMobile(); const { isAutoConnectDisabled } = useMarketplaceContext(); @@ -48,7 +48,7 @@ const WalletMenuMobile = () => { color={ themedColor } borderColor={ !isWalletConnected ? themedBorderColor : undefined } onClick={ isWalletConnected ? openPopover : connect } - isLoading={ isModalOpening || isModalOpen } + isLoading={ (isModalOpening || isModalOpen) && !isWalletConnected } /> { isWalletConnected && ( @@ -61,7 +61,13 @@ const WalletMenuMobile = () => { - + diff --git a/ui/snippets/walletMenu/useWallet.tsx b/ui/snippets/walletMenu/useWallet.tsx index 370c4f4d7e..6fcd209dd4 100644 --- a/ui/snippets/walletMenu/useWallet.tsx +++ b/ui/snippets/walletMenu/useWallet.tsx @@ -45,6 +45,7 @@ export default function useWallet({ source }: Params) { const isWalletConnected = isClientLoaded && !isDisconnected && address !== undefined; return { + openModal: open, isWalletConnected, address: address || '', connect: handleConnect, diff --git a/ui/token/TokenPageTitle.tsx b/ui/token/TokenPageTitle.tsx index c92f1b8d92..cf22c633e7 100644 --- a/ui/token/TokenPageTitle.tsx +++ b/ui/token/TokenPageTitle.tsx @@ -1,11 +1,13 @@ -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 React from 'react'; import type { Address } from 'types/api/address'; import type { TokenInfo } from 'types/api/token'; +import type { EntityTag } from 'ui/shared/EntityTags/types'; import config from 'configs/app'; +import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery'; import type { ResourceError } from 'lib/api/resources'; import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; @@ -14,7 +16,9 @@ import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu' import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; 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 NetworkExplorers from 'ui/shared/NetworkExplorers'; import PageTitle from 'ui/shared/Page/PageTitle'; @@ -24,9 +28,10 @@ import TokenVerifiedInfo from './TokenVerifiedInfo'; interface Props { tokenQuery: UseQueryResult>; addressQuery: UseQueryResult>; + hash: string; } -const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { +const TokenPageTitle = ({ tokenQuery, addressQuery, hash }: Props) => { const appProps = useAppContext(); const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : ''; @@ -35,9 +40,12 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled }, }); - const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData || ( - config.features.verifiedTokens.isEnabled ? verifiedInfoQuery.isPending : false - ); + const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]); + const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery); + + const isLoading = tokenQuery.isPlaceholderData || + addressQuery.isPlaceholderData || + (config.features.verifiedTokens.isEnabled && verifiedInfoQuery.isPending); const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : ''; @@ -54,6 +62,37 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { }; }, [ appProps.referrer ]); + const bridgedTokenTagBgColor = useToken('colors', 'blue.500'); + const bridgedTokenTagTextColor = useToken('colors', 'white'); + + const tags: Array = 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 = ( <> { verifiedInfoQuery.data?.tokenAddress && ( @@ -64,19 +103,8 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => { ) } diff --git a/ui/tx/TxSubHeading.pw.tsx b/ui/tx/TxSubHeading.pw.tsx index 93f51906fb..ce17a7b0fe 100644 --- a/ui/tx/TxSubHeading.pw.tsx +++ b/ui/tx/TxSubHeading.pw.tsx @@ -1,102 +1,61 @@ -import { test, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import * as txMock from 'mocks/txs/tx'; import { txInterpretation } from 'mocks/txs/txInterpretation'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import buildApiUrl from 'playwright/utils/buildApiUrl'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import TxSubHeading from './TxSubHeading'; import type { TxQuery } from './useTxQuery'; const hash = '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193'; -const TX_INTERPRETATION_API_URL = buildApiUrl('tx_interpretation', { hash }); - const txQuery = { data: txMock.base, isPlaceholderData: false, isError: false, } as TxQuery; -test('no interpretation +@mobile', async({ mount }) => { - const component = await mount( - - - , - ); - - 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( - - - , - ); - - 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( - - - , - ); - +test('no interpretation +@mobile', async({ render }) => { + const component = await render(); await expect(component).toHaveScreenshot(); }); -bsInterpretationTest('no interpretation, has method called', async({ mount, page }) => { - await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({ - status: 200, - body: JSON.stringify({ data: { summaries: [] } }), - })); - - const component = await mount( - - - , - ); - - await expect(component).toHaveScreenshot(); -}); - -bsInterpretationTest('no interpretation', async({ mount, page }) => { - const txPendingQuery = { - data: txMock.pending, - isPlaceholderData: false, - isError: false, - } as TxQuery; - await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({ - status: 200, - body: JSON.stringify({ data: { summaries: [] } }), - })); - - const component = await mount( - - - , - ); - - await expect(component).toHaveScreenshot(); +test.describe('blockscout provider', () => { + test.beforeEach(async({ mockEnvs }) => { + await mockEnvs(ENVS_MAP.txInterpretation); + }); + + test('with interpretation +@mobile +@dark-mode', async({ render, mockApiResponse }) => { + await mockApiResponse('tx_interpretation', txInterpretation, { pathParams: { hash } }); + const component = await render(); + await expect(component).toHaveScreenshot(); + }); + + test('with interpretation and view all link +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse( + 'tx_interpretation', + { data: { summaries: [ ...txInterpretation.data.summaries, ...txInterpretation.data.summaries ] } }, + { pathParams: { hash } }, + ); + const component = await render(); + await expect(component).toHaveScreenshot(); + }); + + test('no interpretation, has method called', async({ render, mockApiResponse }) => { + await mockApiResponse('tx_interpretation', { data: { summaries: [] } }, { pathParams: { hash } }); + const component = await render(); + await expect(component).toHaveScreenshot(); + }); + + test('no interpretation', async({ render, mockApiResponse }) => { + const txPendingQuery = { + data: txMock.pending, + isPlaceholderData: false, + isError: false, + } as TxQuery; + await mockApiResponse('tx_interpretation', { data: { summaries: [] } }, { pathParams: { hash } }); + const component = await render(); + await expect(component).toHaveScreenshot(); + }); }); diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_dark-color-mode_with-interpretation-mobile-dark-mode-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_dark-color-mode_blockscout-provider-with-interpretation-mobile-dark-mode-1.png similarity index 100% rename from ui/tx/__screenshots__/TxSubHeading.pw.tsx_dark-color-mode_with-interpretation-mobile-dark-mode-1.png rename to ui/tx/__screenshots__/TxSubHeading.pw.tsx_dark-color-mode_blockscout-provider-with-interpretation-mobile-dark-mode-1.png diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-no-interpretation-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-no-interpretation-1.png new file mode 100644 index 0000000000..873f856877 Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-no-interpretation-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-no-interpretation-has-method-called-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-no-interpretation-has-method-called-1.png new file mode 100644 index 0000000000..bca628b4a6 Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-no-interpretation-has-method-called-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_with-interpretation-and-view-all-link-mobile-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-with-interpretation-and-view-all-link-mobile-1.png similarity index 100% rename from ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_with-interpretation-and-view-all-link-mobile-1.png rename to ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-with-interpretation-and-view-all-link-mobile-1.png diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-with-interpretation-mobile-dark-mode-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-with-interpretation-mobile-dark-mode-1.png new file mode 100644 index 0000000000..96cc6bf0d6 Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_blockscout-provider-with-interpretation-mobile-dark-mode-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-1.png deleted file mode 100644 index 218085ff3e..0000000000 Binary files a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-1.png and /dev/null differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-has-method-called-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-has-method-called-1.png deleted file mode 100644 index 5f168b79d5..0000000000 Binary files a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-has-method-called-1.png and /dev/null differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-mobile-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-mobile-1.png index 218085ff3e..873f856877 100644 Binary files a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-mobile-1.png and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-mobile-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_with-interpretation-and-view-all-link-mobile-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_blockscout-provider-with-interpretation-and-view-all-link-mobile-1.png similarity index 100% rename from ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_with-interpretation-and-view-all-link-mobile-1.png rename to ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_blockscout-provider-with-interpretation-and-view-all-link-mobile-1.png diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_with-interpretation-mobile-dark-mode-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_blockscout-provider-with-interpretation-mobile-dark-mode-1.png similarity index 100% rename from ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_with-interpretation-mobile-dark-mode-1.png rename to ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_blockscout-provider-with-interpretation-mobile-dark-mode-1.png diff --git a/ui/tx/details/TxDetailsWithdrawalStatus.pw.tsx b/ui/tx/details/TxDetailsWithdrawalStatus.pw.tsx index b4cc8c6171..4a3e58473f 100644 --- a/ui/tx/details/TxDetailsWithdrawalStatus.pw.tsx +++ b/ui/tx/details/TxDetailsWithdrawalStatus.pw.tsx @@ -1,12 +1,10 @@ import { Box } from '@chakra-ui/react'; -import { test as base, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import type { OptimisticL2WithdrawalStatus } from 'types/api/optimisticL2'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; -import * as configs from 'playwright/utils/configs'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import TxDetailsWithdrawalStatus from './TxDetailsWithdrawalStatus'; @@ -16,20 +14,13 @@ const statuses: Array = [ 'Relayed', ]; -const test = base.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, -}); - statuses.forEach((status) => { - test(`status="${ status }"`, async({ mount }) => { - - const component = await mount( - - - - - , + test(`status="${ status }"`, async({ render, mockEnvs }) => { + await mockEnvs(ENVS_MAP.optimisticRollup); + const component = await render( + + + , ); await expect(component).toHaveScreenshot(); diff --git a/ui/tx/details/TxInfo.pw.tsx b/ui/tx/details/TxInfo.pw.tsx index 3a81c773d7..2a1d6449d3 100644 --- a/ui/tx/details/TxInfo.pw.tsx +++ b/ui/tx/details/TxInfo.pw.tsx @@ -1,26 +1,14 @@ -import { test, expect } from '@playwright/experimental-ct-react'; import React from 'react'; import * as txMock from 'mocks/txs/tx'; -import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; -import TestApp from 'playwright/TestApp'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; import * as configs from 'playwright/utils/configs'; import TxInfo from './TxInfo'; -const hooksConfig = { - router: { - query: { hash: 1 }, - }, -}; - -test('between addresses +@mobile +@dark-mode', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('between addresses +@mobile +@dark-mode', async({ render, page }) => { + const component = await render(); await page.getByText('View details').click(); @@ -30,13 +18,8 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => { }); }); -test('creating contact', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('creating contact', async({ render, page }) => { + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], @@ -44,13 +27,8 @@ test('creating contact', async({ mount, page }) => { }); }); -test('with token transfer +@mobile', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('with token transfer +@mobile', async({ render, page }) => { + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], @@ -58,13 +36,8 @@ test('with token transfer +@mobile', async({ mount, page }) => { }); }); -test('with decoded revert reason', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('with decoded revert reason', async({ render, page }) => { + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], @@ -72,13 +45,8 @@ test('with decoded revert reason', async({ mount, page }) => { }); }); -test('with decoded raw reason', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('with decoded raw reason', async({ render, page }) => { + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], @@ -86,13 +54,8 @@ test('with decoded raw reason', async({ mount, page }) => { }); }); -test('pending', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('pending', async({ render, page }) => { + const component = await render(); await page.getByText('View details').click(); @@ -102,13 +65,8 @@ test('pending', async({ mount, page }) => { }); }); -test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('with actions uniswap +@mobile +@dark-mode', async({ render, page }) => { + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], @@ -116,13 +74,8 @@ test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => { }); }); -test('with blob', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('with blob', async({ render, page }) => { + const component = await render(); await page.getByText('View details').click(); @@ -132,39 +85,20 @@ test('with blob', async({ mount, page }) => { }); }); -const l2Test = test.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, -}); - -l2Test('l2', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); - +test('l2', async({ render, page, mockEnvs }) => { + await mockEnvs(ENVS_MAP.optimisticRollup); + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], maskColor: configs.maskColor, }); }); -const mainnetTest = test.extend({ - context: contextWithEnvs([ - { name: 'NEXT_PUBLIC_IS_TESTNET', value: 'false' }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ]) as any, -}); - -mainnetTest('without testnet warning', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('without testnet warning', async({ render, page, mockEnvs }) => { + await mockEnvs([ + [ 'NEXT_PUBLIC_IS_TESTNET', 'false' ], + ]); + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], @@ -172,18 +106,9 @@ mainnetTest('without testnet warning', async({ mount, page }) => { }); }); -const stabilityTest = test.extend({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: contextWithEnvs(configs.stabilityEnvs) as any, -}); - -stabilityTest('stability customization', async({ mount, page }) => { - const component = await mount( - - - , - { hooksConfig }, - ); +test('stability customization', async({ render, page, mockEnvs }) => { + await mockEnvs(ENVS_MAP.stabilityEnvs); + const component = await render(); await expect(component).toHaveScreenshot({ mask: [ page.locator(configs.adsBannerSelector) ], diff --git a/ui/tx/details/TxInfo.tsx b/ui/tx/details/TxInfo.tsx index 1d9af024d6..f002a39071 100644 --- a/ui/tx/details/TxInfo.tsx +++ b/ui/tx/details/TxInfo.tsx @@ -160,7 +160,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ) } - { 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 && ( { ) } - { data.zkevm_status && ( + { data.zkevm_status && !config.UI.views.tx.hiddenFields?.L1_status && ( { ) } - { data.zksync && ( + { data.zksync && !config.UI.views.tx.hiddenFields?.L1_status && ( { ) } - { data.zkevm_batch_number && ( + { data.zkevm_batch_number && !config.UI.views.tx.hiddenFields?.batch && ( { /> ) } - { data.zksync && ( + { data.zksync && !config.UI.views.tx.hiddenFields?.batch && (