diff --git a/.github/workflows/deploy-review.yml b/.github/workflows/deploy-review.yml index 4596a3293b..9bc8fb616f 100644 --- a/.github/workflows/deploy-review.yml +++ b/.github/workflows/deploy-review.yml @@ -13,6 +13,7 @@ on: - arbitrum - base - celo_alfajores + - garnet - gnosis - eth - eth_sepolia diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a3b065514e..0d8a70e1ea 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -362,6 +362,7 @@ "arbitrum", "base", "celo_alfajores", + "garnet", "gnosis", "eth", "eth_goerli", diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 8014b8bc04..3b25e5570c 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -18,6 +18,7 @@ export { default as growthBook } from './growthBook'; export { default as marketplace } from './marketplace'; export { default as metasuites } from './metasuites'; export { default as mixpanel } from './mixpanel'; +export { default as mudFramework } from './mudFramework'; export { default as multichainButton } from './multichainButton'; export { default as nameService } from './nameService'; export { default as publicTagsSubmission } from './publicTagsSubmission'; diff --git a/configs/app/features/mudFramework.ts b/configs/app/features/mudFramework.ts new file mode 100644 index 0000000000..86df2af34a --- /dev/null +++ b/configs/app/features/mudFramework.ts @@ -0,0 +1,22 @@ +import type { Feature } from './types'; + +import { getEnvValue } from '../utils'; +import rollup from './rollup'; + +const title = 'MUD framework'; + +const config: Feature<{ isEnabled: true }> = (() => { + if (rollup.isEnabled && rollup.type === 'optimistic' && getEnvValue('NEXT_PUBLIC_HAS_MUD_FRAMEWORK') === 'true') { + return Object.freeze({ + title, + isEnabled: true, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/envs/.env.garnet b/configs/envs/.env.garnet new file mode 100644 index 0000000000..a6763934bc --- /dev/null +++ b/configs/envs/.env.garnet @@ -0,0 +1,50 @@ +# Set of ENVs for Garnet (dev only) +# https://https://explorer.garnetchain.com// + +# app configuration +NEXT_PUBLIC_APP_PROTOCOL=http +NEXT_PUBLIC_APP_HOST=localhost +NEXT_PUBLIC_APP_PORT=3000 + +# blockchain parameters +NEXT_PUBLIC_NETWORK_NAME="Garnet Testnet" +NEXT_PUBLIC_NETWORK_ID=17069 +NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether +NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH +NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 +NEXT_PUBLIC_NETWORK_RPC_URL=https://partner-rpc.garnetchain.com/tireless-strand-dreamt-overcome + +# api configuration +NEXT_PUBLIC_API_HOST=explorer.garnetchain.com +NEXT_PUBLIC_API_BASE_PATH=/ + +# ui config +## homepage +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] +## views +NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] +# app features +NEXT_PUBLIC_APP_INSTANCE=local +NEXT_PUBLIC_APP_ENV=development +NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d +NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true +NEXT_PUBLIC_AUTH_URL=http://localhost:3000/login +NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws +NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com +NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout +NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/redstone-testnet.json +NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/redstone.json +NEXT_PUBLIC_AD_BANNER_PROVIDER=none +## sidebar +NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/garnet.svg +NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/garnet.svg +NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/garnet-dark.svg +NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/garnet-dark.svg +NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=rgb(169, 31, 47) +NEXT_PUBLIC_OG_DESCRIPTION="Redstone is the home for onchain games, worlds, and other MUD applications" +# rollup +NEXT_PUBLIC_ROLLUP_TYPE=optimistic +NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-holesky.blockscout.com/ +NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://garnet.qry.live/withdraw +NEXT_PUBLIC_HAS_MUD_FRAMEWORK=true \ No newline at end of file diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 91bcd20beb..1c215e2552 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -703,6 +703,16 @@ const schema = yup value => value === undefined, ), }), + NEXT_PUBLIC_HAS_MUD_FRAMEWORK: yup.boolean() + .when('NEXT_PUBLIC_ROLLUP_TYPE', { + is: 'optimistic', + then: (schema) => schema, + otherwise: (schema) => schema.test( + 'not-exist', + 'NEXT_PUBLIC_HAS_MUD_FRAMEWORK can only be used with NEXT_PUBLIC_ROLLUP_TYPE=optimistic', + value => value === undefined, + ), + }), // 6. External services envs NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), diff --git a/docs/ENVS.md b/docs/ENVS.md index a542754c9b..629109df8b 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -407,6 +407,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi | NEXT_PUBLIC_ROLLUP_L1_BASE_URL | `string` | Blockscout base URL for L1 network | Required | - | `'http://eth-goerli.blockscout.com'` | v1.24.0+ | | NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL | `string` | URL for L2 -> L1 withdrawals (Optimistic stack only) | Required for `optimistic` rollups | - | `https://app.optimism.io/bridge/withdraw` | v1.24.0+ | | NEXT_PUBLIC_FAULT_PROOF_ENABLED | `boolean` | Set to `true` for chains with fault proof system enabled (Optimistic stack only) | - | - | `true` | v1.31.0+ | +| NEXT_PUBLIC_HAS_MUD_FRAMEWORK | `boolean` | Set to `true` for instances that use MUD framework (Optimistic stack only) | - | - | `true` | - |   diff --git a/icons/MUD.svg b/icons/MUD.svg new file mode 100644 index 0000000000..8ab1229a71 --- /dev/null +++ b/icons/MUD.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/MUD_menu.svg b/icons/MUD_menu.svg new file mode 100644 index 0000000000..c30c571c47 --- /dev/null +++ b/icons/MUD_menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 078fdf099b..eb51e9dc42 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -32,6 +32,12 @@ import type { AddressCollectionsResponse, AddressNFTTokensFilter, AddressCoinBalanceHistoryChartOld, + AddressMudTables, + AddressMudTablesFilter, + AddressMudRecords, + AddressMudRecordsFilter, + AddressMudRecordsSorting, + AddressMudRecord, } from 'types/api/address'; import type { AddressesResponse } from 'types/api/addresses'; import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata'; @@ -61,6 +67,7 @@ import type { import type { IndexingStatus } from 'types/api/indexingStatus'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log'; +import type { MudWorldsResponse } from 'types/api/mudWorlds'; import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves'; import type { OptimisticL2DepositsResponse, @@ -654,6 +661,34 @@ export const RESOURCES = { path: '/api/v2/optimism/games/count', }, + // MUD worlds on optimism + mud_worlds: { + path: '/api/v2/mud/worlds', + filterFields: [], + }, + + address_mud_tables: { + path: '/api/v2/mud/worlds/:hash/tables', + pathParams: [ 'hash' as const ], + filterFields: [ 'q' as const ], + }, + + address_mud_tables_count: { + path: '/api/v2/mud/worlds/:hash/tables/count', + pathParams: [ 'hash' as const ], + }, + + address_mud_records: { + path: '/api/v2/mud/worlds/:hash/tables/:table_id/records', + pathParams: [ 'hash' as const, 'table_id' as const ], + filterFields: [ 'filter_key0' as const, 'filter_key1' as const ], + }, + + address_mud_record: { + path: '/api/v2/mud/worlds/:hash/tables/:table_id/records/:record_id', + pathParams: [ 'hash' as const, 'table_id' as const, 'record_id' as const ], + }, + // arbitrum L2 arbitrum_l2_messages: { path: '/api/v2/arbitrum/messages/:direction', @@ -899,6 +934,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'verified_contracts' | 'optimistic_l2_output_roots' | 'optimistic_l2_withdrawals' | 'optimistic_l2_txn_batches' | 'optimistic_l2_deposits' | 'optimistic_l2_dispute_games' | +'mud_worlds'| 'address_mud_tables' | 'address_mud_records' | 'shibarium_deposits' | 'shibarium_withdrawals' | 'arbitrum_l2_messages' | 'arbitrum_l2_txn_batches' | 'arbitrum_l2_txn_batch_txs' | 'arbitrum_l2_txn_batch_blocks' | 'zkevm_l2_deposits' | 'zkevm_l2_withdrawals' | 'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' | @@ -1056,6 +1092,11 @@ Q extends 'user_op_interpretation'? TxInterpretationResponse : Q extends 'noves_transaction' ? NovesResponseData : Q extends 'noves_address_history' ? NovesAccountHistoryResponse : Q extends 'noves_describe_txs' ? NovesDescribeTxsResponse : +Q extends 'mud_worlds' ? MudWorldsResponse : +Q extends 'address_mud_tables' ? AddressMudTables : +Q extends 'address_mud_tables_count' ? number : +Q extends 'address_mud_records' ? AddressMudRecords : +Q extends 'address_mud_record' ? AddressMudRecord : never; /* eslint-enable @typescript-eslint/indent */ @@ -1087,6 +1128,8 @@ Q extends 'addresses_lookup' ? EnsAddressLookupFilters : Q extends 'domains_lookup' ? EnsDomainLookupFilters : Q extends 'user_ops' ? UserOpsFilters : Q extends 'validators' ? ValidatorsFilters : +Q extends 'address_mud_tables' ? AddressMudTablesFilter : +Q extends 'address_mud_records' ? AddressMudRecordsFilter : never; /* eslint-enable @typescript-eslint/indent */ @@ -1099,5 +1142,6 @@ Q extends 'address_txs' ? TransactionsSorting : Q extends 'addresses_lookup' ? EnsLookupSorting : Q extends 'domains_lookup' ? EnsLookupSorting : Q extends 'validators' ? ValidatorsSorting : +Q extends 'address_mud_records' ? AddressMudRecordsSorting : never; /* eslint-enable @typescript-eslint/indent */ diff --git a/lib/capitalizeFirstLetter.ts b/lib/capitalizeFirstLetter.ts new file mode 100644 index 0000000000..ae054d9423 --- /dev/null +++ b/lib/capitalizeFirstLetter.ts @@ -0,0 +1,7 @@ +export default function capitalizeFirstLetter(text: string) { + if (!text || !text.length) { + return ''; + } + + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/lib/date/dayjs.ts b/lib/date/dayjs.ts index 06b53c01d6..e0191ce1e0 100644 --- a/lib/date/dayjs.ts +++ b/lib/date/dayjs.ts @@ -38,6 +38,7 @@ dayjs.extend(minMax); dayjs.updateLocale('en', { formats: { llll: `MMM DD YYYY HH:mm:ss A (Z${ nbsp }UTC)`, + lll: 'MMM D, YYYY h:mm A', }, relativeTime: { s: '1s', diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 608a6b6d0d..b1a0bf5245 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -102,6 +102,12 @@ export default function useNavItems(): ReturnType { icon: 'games', isActive: pathname === '/dispute-games', } : null; + const mudWorlds = config.features.mudFramework.isEnabled ? { + text: 'MUD worlds', + nextRoute: { pathname: '/mud-worlds' as const }, + icon: 'MUD_menu', + isActive: pathname === '/mud-worlds', + } : null; const rollupFeature = config.features.rollup; @@ -121,6 +127,7 @@ export default function useNavItems(): ReturnType { [ userOps, topAccounts, + mudWorlds, validators, verifiedContracts, ensLookup, diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index f2b1d8df81..a59757301e 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -48,6 +48,7 @@ const OG_TYPE_DICT: Record = { '/name-domains/[name]': 'Regular page', '/validators': 'Root page', '/gas-tracker': 'Root page', + '/mud-worlds': 'Root page', // service routes, added only to make typescript happy '/login': 'Regular page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index 08ea688165..335fd7d3a7 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -52,6 +52,7 @@ const TEMPLATE_MAP: Record = { '/name-domains/[name]': DEFAULT_TEMPLATE, '/validators': DEFAULT_TEMPLATE, '/gas-tracker': DEFAULT_TEMPLATE, + '/mud-worlds': DEFAULT_TEMPLATE, // service routes, added only to make typescript happy '/login': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index 0c41cd0896..904b821a8e 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -48,6 +48,7 @@ const TEMPLATE_MAP: Record = { '/name-domains/[name]': '%network_name% %name% domain details', '/validators': '%network_name% validators list', '/gas-tracker': '%network_name% gas tracker - Current gas fees', + '/mud-worlds': '%network_name% MUD worlds list', // service routes, added only to make typescript happy '/login': '%network_name% login', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index d4e62217d7..0c565caed7 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -46,6 +46,7 @@ export const PAGE_TYPE_DICT: Record = { '/name-domains/[name]': 'Domain details', '/validators': 'Validators list', '/gas-tracker': 'Gas tracker', + '/mud-worlds': 'MUD worlds', // service routes, added only to make typescript happy '/login': 'Login', diff --git a/mocks/mud/mudTables.ts b/mocks/mud/mudTables.ts new file mode 100644 index 0000000000..b5f0186c91 --- /dev/null +++ b/mocks/mud/mudTables.ts @@ -0,0 +1,94 @@ +/* eslint-disable max-len */ +import type { AddressMudRecord, AddressMudRecords, AddressMudRecordsItem, AddressMudTables } from 'types/api/address'; +import type { MudWorldSchema, MudWorldTable } from 'types/api/mudWorlds'; + +export const table1: MudWorldTable = { + table_full_name: 'tb.store.Tables', + table_id: '0x746273746f72650000000000000000005461626c657300000000000000000000', + table_name: 'Tables', + table_namespace: 'store', + table_type: 'onchain', +}; + +export const table2: MudWorldTable = { + table_full_name: 'ot.world.FunctionSignatur', + table_id: '0x6f74776f726c6400000000000000000046756e6374696f6e5369676e61747572', + table_name: 'FunctionSignatur', + table_namespace: 'world', + table_type: 'offchain', +}; + +export const schema1: MudWorldSchema = { + key_names: [ 'moduleAddress', 'argumentsHash' ], + key_types: [ 'address', 'bytes32' ], + value_names: [ 'fieldLayout', 'keySchema', 'valueSchema', 'abiEncodedKeyNames', 'abiEncodedFieldNames' ], + value_types: [ 'bytes32', 'bytes32', 'bytes32', 'bytes', 'bytes' ], +}; + +export const schema2: MudWorldSchema = { + key_names: [], + key_types: [], + value_names: [ 'value' ], + value_types: [ 'address' ], +}; + +export const mudTables: AddressMudTables = { + items: [ + { + table: table1, + schema: schema1, + }, + { + table: table2, + schema: schema2, + }, + ], + next_page_params: { + items_count: 50, + table_id: '1', + }, +}; + +const record: AddressMudRecordsItem = { + decoded: { + abiEncodedFieldNames: '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000006706c617965720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000576616c7565000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000974696d657374616d700000000000000000000000000000000000000000000000', + abiEncodedKeyNames: '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000026964000000000000000000000000000000000000000000000000000000000000', + goldCosts: [ '100000', '150000', '200000', '250000', '400000', '550000', '700000' ], + prototypeIds: [ + '0x53776f7264736d616e0000000000000000000000000000000000000000000000', + '0x50696b656d616e00000000000000000000000000000000000000000000000000', + '0x50696b656d616e00000000000000000000000000000000000000000000000000', + '0x4172636865720000000000000000000000000000000000000000000000000000', + '0x4b6e696768740000000000000000000000000000000000000000000000000000', + ], + keySchema: '0x002001001f000000000000000000000000000000000000000000000000000000', + tableId: '0x6f74000000000000000000000000000044726177557064617465000000000000', + valueSchema: '0x00540300611f1f00000000000000000000000000000000000000000000000000', + }, + id: '0x007a651a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007', + is_deleted: false, + timestamp: '2024-05-09T15:14:32.000000Z', +}; + +export const mudRecords: AddressMudRecords = { + items: [ record, record ], + next_page_params: { + items_count: 50, + key0: '1', + key1: '2', + key_bytes: '3', + }, + schema: { + key_names: [ 'tableId' ], + key_types: [ 'bytes32' ], + value_names: [ 'prototypeIds', 'goldCosts', 'keySchema', 'valueSchema', 'abiEncodedKeyNames', 'abiEncodedFieldNames' ], + value_types: [ 'bytes32[]', 'int32[]', 'bytes32', 'bytes32', 'bytes32', 'bytes', 'bytes' ], + }, + table: table1, +}; + +export const mudRecord: AddressMudRecord = { + record, + schema: mudRecords.schema, + table: table1, +}; diff --git a/mocks/mud/mudWorlds.ts b/mocks/mud/mudWorlds.ts new file mode 100644 index 0000000000..2d8218e884 --- /dev/null +++ b/mocks/mud/mudWorlds.ts @@ -0,0 +1,27 @@ +import type { MudWorldsResponse } from 'types/api/mudWorlds'; + +import { withName, withoutName } from 'mocks/address/address'; + +export const mudWorlds: MudWorldsResponse = { + items: [ + { + address: withName, + coin_balance: '300000000000000000', + tx_count: 3938, + }, + { + address: withoutName, + coin_balance: '0', + tx_count: 0, + }, + { + address: withoutName, + coin_balance: '0', + tx_count: 0, + }, + ], + next_page_params: { + items_count: 50, + world: '0x18f01f12ca21b6fc97b917c3e32f671f8a933caa', + }, +}; diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index ccaaf9763b..63103ae355 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -275,3 +275,13 @@ export const disputeGames: GetServerSideProps = async(context) => { return base(context); }; + +export const mud: GetServerSideProps = async(context) => { + if (!config.features.mudFramework.isEnabled) { + return { + notFound: true, + }; + } + + return base(context); +}; diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index 6f4e01bfee..6a3fb43e7b 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -44,6 +44,7 @@ declare module "nextjs-routes" { | StaticRoute<"/graphiql"> | StaticRoute<"/"> | StaticRoute<"/login"> + | StaticRoute<"/mud-worlds"> | DynamicRoute<"/name-domains/[name]", { "name": string }> | StaticRoute<"/name-domains"> | DynamicRoute<"/op/[hash]", { "hash": string }> diff --git a/pages/mud-worlds.tsx b/pages/mud-worlds.tsx new file mode 100644 index 0000000000..5e970720c4 --- /dev/null +++ b/pages/mud-worlds.tsx @@ -0,0 +1,19 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import PageNextJs from 'nextjs/PageNextJs'; + +const MudWorlds = dynamic(() => import('ui/pages/MudWorlds'), { ssr: false }); + +const Page: NextPage = () => { + return ( + + + + ); +}; + +export default Page; + +export { mud as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 333de6e887..eb787ac206 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -91,6 +91,8 @@ | "monaco/vyper" | "moon-with-star" | "moon" + | "MUD_menu" + | "MUD" | "networks" | "networks/icon-placeholder" | "networks/logo-placeholder" diff --git a/stubs/address.ts b/stubs/address.ts index bc03c46aa6..dbdc51d132 100644 --- a/stubs/address.ts +++ b/stubs/address.ts @@ -3,6 +3,7 @@ import type { AddressCoinBalanceHistoryItem, AddressCollection, AddressCounters, + AddressMudTableItem, AddressNFT, AddressTabsCounters, AddressTokenBalance, @@ -10,6 +11,7 @@ import type { import type { AddressesItem } from 'types/api/addresses'; import { ADDRESS_HASH } from './addressParams'; +import { MUD_SCHEMA, MUD_TABLE } from './mud'; import { TOKEN_INFO_ERC_1155, TOKEN_INFO_ERC_20, TOKEN_INFO_ERC_721, TOKEN_INFO_ERC_404, TOKEN_INSTANCE } from './token'; import { TX_HASH } from './tx'; @@ -109,3 +111,8 @@ export const ADDRESS_COLLECTION: AddressCollection = { amount: '4', token_instances: Array(4).fill(TOKEN_INSTANCE), }; + +export const ADDRESS_MUD_TABLE_ITEM: AddressMudTableItem = { + schema: MUD_SCHEMA, + table: MUD_TABLE, +}; diff --git a/stubs/mud.ts b/stubs/mud.ts new file mode 100644 index 0000000000..d49db88913 --- /dev/null +++ b/stubs/mud.ts @@ -0,0 +1,24 @@ +import type { MudWorldItem, MudWorldSchema, MudWorldTable } from 'types/api/mudWorlds'; + +import { ADDRESS_PARAMS } from './addressParams'; + +export const MUD_TABLE: MudWorldTable = { + table_full_name: 'ot.Match', + table_id: '0x6f7400000000000000000000000000004d617463680000000000000000000000', + table_name: 'Match', + table_namespace: '', + table_type: 'offchain', +}; + +export const MUD_SCHEMA: MudWorldSchema = { + key_names: [ 'matchEntityKey', 'entity' ], + key_types: [ 'bytes32', 'bytes32' ], + value_names: [ 'matchEntity' ], + value_types: [ 'bytes32' ], +}; + +export const MUD_WORLD: MudWorldItem = { + address: ADDRESS_PARAMS, + coin_balance: '7072643779453701031672', + tx_count: 442, +}; diff --git a/types/api/address.ts b/types/api/address.ts index 71eb576e65..d8235125e0 100644 --- a/types/api/address.ts +++ b/types/api/address.ts @@ -3,6 +3,7 @@ import type { Transaction } from 'types/api/transaction'; import type { UserTags, AddressImplementation } from './addressParams'; import type { Block } from './block'; import type { InternalTransaction } from './internalTransaction'; +import type { MudWorldSchema, MudWorldTable } from './mudWorlds'; import type { NFTTokenType, TokenInfo, TokenInstance, TokenType } from './token'; import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer'; @@ -197,3 +198,56 @@ export type AddressTabsCounters = { validations_count: number | null; withdrawals_count: number | null; } + +// MUD framework +export type AddressMudTableItem = { + schema: MudWorldSchema; + table: MudWorldTable; +} + +export type AddressMudTables = { + items: Array; + next_page_params: { + items_count: number; + table_id: string; + }; +} + +export type AddressMudTablesFilter = { + q?: string; +} + +export type AddressMudRecords = { + items: Array; + schema: MudWorldSchema; + table: MudWorldTable; + next_page_params: { + items_count: number; + key0: string; + key1: string; + key_bytes: string; + }; +} + +export type AddressMudRecordsItem = { + decoded: Record>; + id: string; + is_deleted: boolean; + timestamp: string; +} + +export type AddressMudRecordsFilter = { + filter_key0?: string; + filter_key1?: string; +} + +export type AddressMudRecordsSorting = { + sort: 'key0' | 'key1'; + order: 'asc' | 'desc' | undefined; +} + +export type AddressMudRecord = { + record: AddressMudRecordsItem; + schema: MudWorldSchema; + table: MudWorldTable; +} diff --git a/types/api/mudWorlds.ts b/types/api/mudWorlds.ts new file mode 100644 index 0000000000..b9c5eb4d91 --- /dev/null +++ b/types/api/mudWorlds.ts @@ -0,0 +1,30 @@ +import type { AddressParam } from './addressParams'; + +export type MudWorldsResponse = { + items: Array; + next_page_params: { + items_count: number; + world: string; + }; +} + +export type MudWorldItem = { + address: AddressParam; + coin_balance: string; + tx_count: number | null; +} + +export type MudWorldSchema = { + key_names: Array; + key_types: Array; + value_names: Array; + value_types: Array; +}; + +export type MudWorldTable = { + table_full_name: string; + table_id: string; + table_name: string; + table_namespace: string; + table_type: string; +} diff --git a/ui/address/AddressMud.tsx b/ui/address/AddressMud.tsx new file mode 100644 index 0000000000..1848571125 --- /dev/null +++ b/ui/address/AddressMud.tsx @@ -0,0 +1,37 @@ +import { useRouter } from 'next/router'; +import React from 'react'; + +import useIsMounted from 'lib/hooks/useIsMounted'; + +import AddressMudRecord from './mud/AddressMudRecord'; +import AddressMudTable from './mud/AddressMudTable'; +import AddressMudTables from './mud/AddressMudTables'; + +type Props ={ + scrollRef?: React.RefObject; + shouldRender?: boolean; + isQueryEnabled?: boolean; +} + +const AddressMud = ({ scrollRef, shouldRender = true, isQueryEnabled = true }: Props) => { + const isMounted = useIsMounted(); + const router = useRouter(); + const tableId = router.query.table_id?.toString(); + const recordId = router.query.record_id?.toString(); + + if (!isMounted || !shouldRender) { + return null; + } + + if (tableId && recordId) { + return ; + } + + if (tableId) { + return ; + } + + return ; +}; + +export default AddressMud; diff --git a/ui/address/contract/ContractCode.pw.tsx b/ui/address/contract/ContractCode.pw.tsx index 6d6988bead..b076c5c8c7 100644 --- a/ui/address/contract/ContractCode.pw.tsx +++ b/ui/address/contract/ContractCode.pw.tsx @@ -21,7 +21,10 @@ test.describe.configure({ mode: 'serial' }); let addressApiUrl: string; -test.beforeEach(async({ mockApiResponse }) => { +test.beforeEach(async({ mockApiResponse, page }) => { + await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => { + route.abort(); + }); addressApiUrl = await mockApiResponse('address', addressMock.contract, { pathParams: { hash: addressMock.contract.hash } }); }); diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_dark-color-mode_full-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_dark-color-mode_full-view-mobile-dark-mode-1.png index 814ff164cb..3db7025fd4 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_dark-color-mode_full-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_dark-color-mode_full-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_full-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_full-view-mobile-dark-mode-1.png index 6946d80268..2108d45306 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_full-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_full-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-changed-byte-code-socket-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-changed-byte-code-socket-1.png index bc1d1c188f..241191634a 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-changed-byte-code-socket-1.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-changed-byte-code-socket-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-1.png index ea356a3707..bb3d1162ac 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-1.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-2.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-2.png index bd67c84fe5..faed372ebc 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-2.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-3.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-3.png index 3c725a06a7..7d9285311e 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-3.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-3.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-has-audits-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-has-audits-1.png index fc8d135af3..8a0b09c4ea 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-has-audits-1.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-has-audits-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-no-audits-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-no-audits-1.png index e4af86a879..1601567f2c 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-no-audits-1.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-no-audits-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_full-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_full-view-mobile-dark-mode-1.png index 0d25fd3369..3f5ad28058 100644 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_full-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_full-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methods/ContractAbiItem.tsx b/ui/address/contract/methods/ContractAbiItem.tsx index 1aea355880..9796b12d47 100644 --- a/ui/address/contract/methods/ContractAbiItem.tsx +++ b/ui/address/contract/methods/ContractAbiItem.tsx @@ -1,4 +1,4 @@ -import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react'; +import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box } from '@chakra-ui/react'; import React from 'react'; import { Element } from 'react-scroll'; @@ -10,7 +10,6 @@ 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'; import ContractMethodForm from './form/ContractMethodForm'; import { getElementName } from './useScrollToMethod'; @@ -42,13 +41,9 @@ const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit }: Props) }); }, [ addressHash, data, tab ]); - const { hasCopied, onCopy } = useClipboard(url, 1000); - const methodIdTooltip = useDisclosure(); - const handleCopyLinkClick = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); - onCopy(); - }, [ onCopy ]); + }, []); const handleCopyMethodIdClick = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); @@ -64,21 +59,7 @@ const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit }: Props) <> - { 'method_id' in data && ( - - - - - - ) } + { 'method_id' in data && } { index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name } diff --git a/ui/address/mud/AddressMudBreadcrumbs.tsx b/ui/address/mud/AddressMudBreadcrumbs.tsx new file mode 100644 index 0000000000..5d2eedff40 --- /dev/null +++ b/ui/address/mud/AddressMudBreadcrumbs.tsx @@ -0,0 +1,119 @@ +import { Box, useColorModeValue, chakra, Grid } from '@chakra-ui/react'; +import React from 'react'; + +import { route } from 'nextjs-routes'; + +import useIsMobile from 'lib/hooks/useIsMobile'; +import isBrowser from 'lib/isBrowser'; +import CopyToClipboard from 'ui/shared/CopyToClipboard'; +import IconSvg from 'ui/shared/IconSvg'; +import LinkInternal from 'ui/shared/links/LinkInternal'; + +import useAddressQuery from '../utils/useAddressQuery'; + +type TableViewProps = { + scrollRef?: React.RefObject; + className?: string; + hash: string; + tableId: string; + tableName: string; +} + +type RecordViewProps = TableViewProps & { + recordId: string; + recordName: string; +} + +type BreadcrumbItemProps = { + scrollRef?: React.RefObject; + text: string; + href: string; + isLast?: boolean; +} + +const BreadcrumbItem = ({ text, href, isLast, scrollRef }: BreadcrumbItemProps) => { + const iconColor = useColorModeValue('gray.300', 'gray.600'); + + const currentUrl = isBrowser() ? window.location.href : ''; + + const onLinkClick = React.useCallback(() => { + window.setTimeout(() => { + // cannot do scroll instantly, have to wait a little + scrollRef?.current?.scrollIntoView({ behavior: 'smooth' }); + }, 500); + }, [ scrollRef ]); + + if (isLast) { + return ( + + + { text } + + + + ); + } + + return ( + + + { text } + + { !isLast && } + + ); +}; + +const AddressMudBreadcrumbs = (props: TableViewProps | RecordViewProps) => { + const queryParams = { tab: 'mud', hash: props.hash }; + const isMobile = useIsMobile(); + + const addressQuery = useAddressQuery({ hash: props.hash }); + + return ( + + + + + { ('recordId' in props) && ( + + ) } + + ); +}; + +export default React.memo(chakra(AddressMudBreadcrumbs)); diff --git a/ui/address/mud/AddressMudRecord.pw.tsx b/ui/address/mud/AddressMudRecord.pw.tsx new file mode 100644 index 0000000000..bd806698ff --- /dev/null +++ b/ui/address/mud/AddressMudRecord.pw.tsx @@ -0,0 +1,46 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { mudRecord } from 'mocks/mud/mudTables'; +import { test, expect, devices } from 'playwright/lib'; + +import AddressMudRecord from './AddressMudRecord'; + +const ADDRESS_HASH = 'hash'; +const TABLE_ID = '123'; +const RECORD_ID = '234'; +const hooksConfig = { + router: { + query: { hash: ADDRESS_HASH }, + }, +}; + +test('base view', async({ render, mockApiResponse }) => { + await mockApiResponse('address_mud_record', mudRecord, { pathParams: { hash: ADDRESS_HASH, table_id: TABLE_ID, record_id: RECORD_ID } }); + + const component = await render( + + + , + { hooksConfig }, + ); + + await expect(component).toHaveScreenshot(); +}); + +test.describe('mobile', () => { + test.use({ viewport: devices['iPhone 13 Pro'].viewport }); + + test('base view', async({ render, mockApiResponse }) => { + await mockApiResponse('address_mud_record', mudRecord, { pathParams: { hash: ADDRESS_HASH, table_id: TABLE_ID, record_id: RECORD_ID } }); + + const component = await render( + + + , + { hooksConfig }, + ); + + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/address/mud/AddressMudRecord.tsx b/ui/address/mud/AddressMudRecord.tsx new file mode 100644 index 0000000000..0b3ef6df93 --- /dev/null +++ b/ui/address/mud/AddressMudRecord.tsx @@ -0,0 +1,94 @@ +import { Box, Td, Tr, Flex, Text, Table, Show, Hide, Divider, VStack } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import useApiQuery from 'lib/api/useApiQuery'; +import dayjs from 'lib/date/dayjs'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import ContentLoader from 'ui/shared/ContentLoader'; +import TruncatedValue from 'ui/shared/TruncatedValue'; + +import AddressMudBreadcrumbs from './AddressMudBreadcrumbs'; +import AddressMudRecordValues from './AddressMudRecordValues'; +import { getValueString } from './utils'; + +type Props ={ + scrollRef?: React.RefObject; + isQueryEnabled?: boolean; + tableId: string; + recordId: string; +} + +const AddressMudRecord = ({ tableId, recordId, isQueryEnabled = true, scrollRef }: Props) => { + const router = useRouter(); + + const hash = getQueryParamString(router.query.hash); + + const { data, isLoading, isError } = useApiQuery('address_mud_record', { + pathParams: { hash, table_id: tableId, record_id: recordId }, + queryOptions: { + enabled: isQueryEnabled, + }, + }); + + if (isLoading) { + return ; + } + + if (isError) { + return error message; + } + + return ( + <> + { data && ( + + ) } + + + { data?.schema.key_names.length && data?.schema.key_names.map((keyName, index) => ( + + + + + )) } + +
+ { keyName } ({ data.schema.key_types[index] }) + + + + { index === 0 && { dayjs(data.record.timestamp).format('lll') } } + +
+
+ + <> + { data?.schema.key_names.length && data?.schema.key_names.map((keyName, index) => ( + + + + { keyName } ({ data.schema.key_types[index] }) + + { getValueString(data.record.decoded[keyName]) } + { index === 0 && { dayjs(data.record.timestamp).format('lll') } } + + )) } + + +
+ +
+ + ); +}; + +export default AddressMudRecord; diff --git a/ui/address/mud/AddressMudRecordValues.tsx b/ui/address/mud/AddressMudRecordValues.tsx new file mode 100644 index 0000000000..9deb9f63a1 --- /dev/null +++ b/ui/address/mud/AddressMudRecordValues.tsx @@ -0,0 +1,43 @@ +import { Box, Td, Tr, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +import type { AddressMudRecord } from 'types/api/address'; + +import { getValueString } from './utils'; + +type Props ={ + data?: AddressMudRecord; +} + +const AddressMudRecordValues = ({ data }: Props) => { + const valuesBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50'); + + if (!data?.schema.value_names.length) { + return null; + } + + return ( + <> + + Field + Type + Value + + { + data?.schema.value_names.map((valName, index) => ( + + { valName } + { data.schema.value_types[index] } + + + { getValueString(data.record.decoded[valName]) } + + + + )) + } + + ); +}; + +export default AddressMudRecordValues; diff --git a/ui/address/mud/AddressMudRecordsKeyFilter.tsx b/ui/address/mud/AddressMudRecordsKeyFilter.tsx new file mode 100644 index 0000000000..43e24c85af --- /dev/null +++ b/ui/address/mud/AddressMudRecordsKeyFilter.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import FilterInput from 'ui/shared/filters/FilterInput'; +import TableColumnFilter from 'ui/shared/filters/TableColumnFilter'; + +type Props = { + value?: string; + handleFilterChange: (val: string) => void; + title: string; + columnName: string; + isLoading?: boolean; +} + +const AddressMudRecordsKeyFilter = ({ value = '', handleFilterChange, columnName, title, isLoading }: Props) => { + const [ filterValue, setFilterValue ] = React.useState(value); + + const onFilter = React.useCallback(() => { + handleFilterChange(filterValue); + }, [ handleFilterChange, filterValue ]); + + return ( + + + + ); +}; + +export default AddressMudRecordsKeyFilter; diff --git a/ui/address/mud/AddressMudRecordsTable.tsx b/ui/address/mud/AddressMudRecordsTable.tsx new file mode 100644 index 0000000000..aa829b2c2a --- /dev/null +++ b/ui/address/mud/AddressMudRecordsTable.tsx @@ -0,0 +1,219 @@ +import type { StyleProps } from '@chakra-ui/react'; +import { Box, Link, Table, Tbody, Td, Th, Tr, Flex, useColorModeValue, useBoolean, Tooltip } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { AddressMudRecords, AddressMudRecordsFilter, AddressMudRecordsSorting } from 'types/api/address'; + +import { route } from 'nextjs-routes'; + +import capitalizeFirstLetter from 'lib/capitalizeFirstLetter'; +import dayjs from 'lib/date/dayjs'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import IconSvg from 'ui/shared/IconSvg'; +import LinkInternal from 'ui/shared/links/LinkInternal'; +import { default as Thead } from 'ui/shared/TheadSticky'; + +import AddressMudRecordsKeyFilter from './AddressMudRecordsKeyFilter'; +import { getNameTypeText, getValueString } from './utils'; + +const COL_MIN_WIDTH = 180; +const COL_MIN_WIDTH_MOBILE = 140; +const CUT_COL_WIDTH = 36; +const MIN_CUT_COUNT = 2; + +type Props = { + data: AddressMudRecords; + top: number; + sorting?: AddressMudRecordsSorting; + toggleSorting: (key: AddressMudRecordsSorting['sort']) => void; + setFilters: React.Dispatch>; + filters: AddressMudRecordsFilter; + toggleTableHasHorisontalScroll: () => void; + scrollRef?: React.RefObject; + hash: string; +} + +const AddressMudRecordsTable = ({ + data, + top, + sorting, + toggleSorting, + filters, + setFilters, + toggleTableHasHorisontalScroll, + scrollRef, + hash, +}: Props) => { + const totalColsCut = data.schema.key_names.length + data.schema.value_names.length; + const isMobile = useIsMobile(false); + const [ colsCutCount, setColsCutCount ] = React.useState(isMobile ? 2 : 0); + const [ isOpened, setIsOpened ] = useBoolean(false); + const [ hasCut, setHasCut ] = useBoolean(isMobile ? totalColsCut > MIN_CUT_COUNT : true); + + const containerRef = React.useRef(null); + const tableRef = React.useRef(null); + + const router = useRouter(); + + const toggleIsOpen = React.useCallback(() => { + isOpened && tableRef.current?.scroll({ left: 0 }); + setIsOpened.toggle(); + toggleTableHasHorisontalScroll(); + }, [ setIsOpened, toggleTableHasHorisontalScroll, isOpened ]); + + const onRecordClick = React.useCallback((e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey) { + // Allow opening in a new tab/window with right-click or ctrl/cmd+click + return; + } + + e.preventDefault(); + + router.push( + { pathname: '/address/[hash]', query: { hash, tab: 'mud', table_id: data.table.table_id, record_id: e.currentTarget.getAttribute('data-id') as string } }, + undefined, + { shallow: true }, + ); + scrollRef?.current?.scrollIntoView(); + }, [ router, scrollRef, hash, data.table.table_id ]); + + const handleFilterChange = React.useCallback((field: keyof AddressMudRecordsFilter) => (val: string) => { + setFilters(prev => { + const newVal = { ...prev }; + newVal[field] = val; + return newVal; + }); + }, [ setFilters ]); + + const onKeySortClick = React.useCallback( + (e: React.MouseEvent) => toggleSorting('key' + e.currentTarget.getAttribute('data-id') as AddressMudRecordsSorting['sort']), + [ toggleSorting ], + ); + + const keyBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50'); + + React.useEffect(() => { + if (hasCut && !colsCutCount && containerRef.current) { + const count = Math.floor((containerRef.current.getBoundingClientRect().width - CUT_COL_WIDTH) / COL_MIN_WIDTH); + if (totalColsCut > 2 && count - 1 < totalColsCut) { + setColsCutCount(count - 1); + } else { + setHasCut.off(); + } + } + }, [ colsCutCount, data.schema, hasCut, setHasCut, totalColsCut ]); + + const colW = isMobile ? COL_MIN_WIDTH_MOBILE : COL_MIN_WIDTH; + + const tdStyles: StyleProps = { + wordBreak: 'break-word', + whiteSpace: 'normal', + minW: `${ colW }px`, + w: `${ colW }px`, + verticalAlign: 'top', + lineHeight: '20px', + }; + + const keys = (isOpened || !hasCut) ? data.schema.key_names : data.schema.key_names.slice(0, colsCutCount); + const values = (isOpened || !hasCut) ? data.schema.value_names : data.schema.value_names.slice(0, colsCutCount - data.schema.key_names.length); + + const hasHorizontalScroll = isMobile || isOpened; + + if (hasCut && !colsCutCount) { + return ; + } + + const cutButton = ( + + + ... + + + ); + + return ( + // can't implement both horisontal table scroll and sticky header + + + + + { keys.map((keyName, index) => { + const text = getNameTypeText(keyName, data.schema.key_types[index]); + return ( + + ); + }) } + { values.map((valName, index) => ( + + )) } + { hasCut && !isOpened && cutButton } + + { hasCut && isOpened && cutButton } + + + + { data.items.map((item) => ( + + { keys.map((keyName, index) => ( + + )) } + { values.map((valName) => + ) } + { hasCut && !isOpened && } + + { hasCut && isOpened && } + + )) } + +
+ { index < 2 ? ( + + + { sorting?.sort === `key${ index }` && sorting.order && ( + + + + ) } + { text } + + + + + + ) : text } + + { capitalizeFirstLetter(valName) } ({ data.schema.value_types[index] }) + Modified
+ { index === 0 ? ( + + { getValueString(item.decoded[keyName]) } + + ) : getValueString(item.decoded[keyName]) } + { getValueString(item.decoded[valName]) }{ dayjs(item.timestamp).format('lll') }
+
+ ); +}; + +export default AddressMudRecordsTable; diff --git a/ui/address/mud/AddressMudTable.pw.tsx b/ui/address/mud/AddressMudTable.pw.tsx new file mode 100644 index 0000000000..a5e5b1116c --- /dev/null +++ b/ui/address/mud/AddressMudTable.pw.tsx @@ -0,0 +1,59 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { mudRecords } from 'mocks/mud/mudTables'; +import { test, expect } from 'playwright/lib'; + +import AddressMudTable from './AddressMudTable'; + +const ADDRESS_HASH = 'hash'; +const TABLE_ID = '123'; +const hooksConfig = { + router: { + query: { hash: ADDRESS_HASH }, + }, +}; + +test('base view +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('address_mud_records', mudRecords, { pathParams: { hash: ADDRESS_HASH, table_id: TABLE_ID } }); + + const component = await render( + + + , + { hooksConfig }, + ); + + await expect(component).toHaveScreenshot(); +}); + +test('expanded view +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('address_mud_records', mudRecords, { pathParams: { hash: ADDRESS_HASH, table_id: TABLE_ID } }); + + const component = await render( + + + , + { hooksConfig }, + ); + + await component.locator('a[aria-label="show/hide columns"]').first().click(); + + await expect(component).toHaveScreenshot(); +}); + +test('empty +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse( + 'address_mud_records', + { ...mudRecords, items: [] }, + { pathParams: { hash: ADDRESS_HASH, table_id: TABLE_ID } }); + + const component = await render( + + + , + { hooksConfig }, + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/address/mud/AddressMudTable.tsx b/ui/address/mud/AddressMudTable.tsx new file mode 100644 index 0000000000..e106a17004 --- /dev/null +++ b/ui/address/mud/AddressMudTable.tsx @@ -0,0 +1,161 @@ +import { Box, HStack, Tag, TagCloseButton, chakra, useBoolean } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { AddressMudRecordsFilter, AddressMudRecordsSorting } from 'types/api/address'; + +import useIsMobile from 'lib/hooks/useIsMobile'; +import { apos, nbsp } from 'lib/html-entities'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import ContentLoader from 'ui/shared/ContentLoader'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; +import { getNextOrderValue } from 'ui/shared/sort/getNextSortValue'; +import getSortParamsFromQuery from 'ui/shared/sort/getSortParamsFromQuery'; + +import AddressMudBreadcrumbs from './AddressMudBreadcrumbs'; +import AddressMudRecordsTable from './AddressMudRecordsTable'; +import { getNameTypeText, SORT_SEQUENCE } from './utils'; + +type Props ={ + scrollRef?: React.RefObject; + isQueryEnabled?: boolean; + tableId: string; +} + +type FilterKeys = keyof AddressMudRecordsFilter; + +const AddressMudTable = ({ scrollRef, tableId, isQueryEnabled = true }: Props) => { + const router = useRouter(); + const [ sorting, setSorting ] = + React.useState(getSortParamsFromQuery(router.query, SORT_SEQUENCE)); + const [ filters, setFilters ] = React.useState({}); + const isMobile = useIsMobile(); + const [ tableHasHorisontalScroll, setTableHasHorisontalScroll ] = useBoolean(isMobile); + + const hash = getQueryParamString(router.query.hash); + + const { data, isLoading, isError, pagination, onSortingChange } = useQueryWithPages({ + resourceName: 'address_mud_records', + pathParams: { hash, table_id: tableId }, + filters, + sorting, + scrollRef, + options: { + // no placeholder data because the structure of a table is unpredictable + enabled: isQueryEnabled, + }, + }); + + const toggleSorting = React.useCallback((val: AddressMudRecordsSorting['sort']) => { + const newSorting = { sort: val, order: getNextOrderValue(sorting?.sort === val ? sorting.order : undefined) }; + setSorting(newSorting); + onSortingChange(newSorting); + }, [ onSortingChange, sorting ]); + + const onRemoveFilterClick = React.useCallback((key: FilterKeys) => () => { + setFilters(prev => { + const newFilters = { ...prev }; + delete newFilters[key]; + return newFilters; + }); + }, []); + + const hasActiveFilters = Object.values(filters).some(Boolean); + + const actionBatHeight = React.useMemo(() => { + const heightWithoutFilters = pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 60; + + return hasActiveFilters ? heightWithoutFilters + 44 : heightWithoutFilters; + }, [ pagination.isVisible, hasActiveFilters ]); + + if (isLoading) { + return ; + } + + const filtersTags = hasActiveFilters ? ( + + { Object.entries(filters).map(([ key, value ]) => { + const index = key as FilterKeys === 'filter_key0' ? 0 : 1; + return ( + + { + getNameTypeText(data?.schema.key_names[index] || '', data?.schema.key_types[index] || '') } + + + { nbsp } + { value } + + + + ); + }) } + + ) : null; + + const breadcrumbs = data ? ( + + ) : null; + + const actionBar = (!isMobile || hasActiveFilters || pagination.isVisible) && ( + + + { !isMobile && breadcrumbs } + { filtersTags } + + + + ); + + const content = data?.items ? ( + + ) : null; + + const emptyText = ( + <> + There are no records for + { data?.table.table_full_name ? { data?.table.table_full_name } : 'this table' } + + ); + + return ( + <> + { isMobile && ( + { breadcrumbs } + ) } + + + ); +}; + +export default AddressMudTable; diff --git a/ui/address/mud/AddressMudTables.pw.tsx b/ui/address/mud/AddressMudTables.pw.tsx new file mode 100644 index 0000000000..b9bdbabd5f --- /dev/null +++ b/ui/address/mud/AddressMudTables.pw.tsx @@ -0,0 +1,42 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { mudTables } from 'mocks/mud/mudTables'; +import { test, expect } from 'playwright/lib'; + +import AddressMudTables from './AddressMudTables'; + +const ADDRESS_HASH = 'hash'; +const hooksConfig = { + router: { + query: { hash: ADDRESS_HASH, q: 'o' }, + }, +}; + +test('base view +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('address_mud_tables', mudTables, { pathParams: { hash: ADDRESS_HASH }, queryParams: { q: 'o' } }); + + const component = await render( + + + , + { hooksConfig }, + ); + + await expect(component).toHaveScreenshot(); +}); + +test('with schema opened +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('address_mud_tables', mudTables, { pathParams: { hash: ADDRESS_HASH }, queryParams: { q: 'o' } }); + + const component = await render( + + + , + { hooksConfig }, + ); + + await component.locator('div[aria-label="View schema"]').first().click(); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/address/mud/AddressMudTables.tsx b/ui/address/mud/AddressMudTables.tsx new file mode 100644 index 0000000000..50c2a6b70b --- /dev/null +++ b/ui/address/mud/AddressMudTables.tsx @@ -0,0 +1,107 @@ +import { Hide, Show } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import useDebounce from 'lib/hooks/useDebounce'; +import useIsInitialLoading from 'lib/hooks/useIsInitialLoading'; +import { apos } from 'lib/html-entities'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { ADDRESS_MUD_TABLE_ITEM } from 'stubs/address'; +import { generateListStub } from 'stubs/utils'; +import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import FilterInput from 'ui/shared/filters/FilterInput'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; + +import AddressMudTablesListItem from './AddressMudTablesListItem'; +import AddressMudTablesTable from './AddressMudTablesTable'; + +type Props ={ + scrollRef?: React.RefObject; + isQueryEnabled?: boolean; +} + +const AddressMudTables = ({ scrollRef, isQueryEnabled = true }: Props) => { + const router = useRouter(); + + const hash = getQueryParamString(router.query.hash); + const q = getQueryParamString(router.query.q); + const [ searchTerm, setSearchTerm ] = React.useState(q || ''); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({ + resourceName: 'address_mud_tables', + pathParams: { hash }, + filters: { q: debouncedSearchTerm }, + scrollRef, + options: { + enabled: isQueryEnabled, + placeholderData: generateListStub<'address_mud_tables'>(ADDRESS_MUD_TABLE_ITEM, 3, { next_page_params: { + items_count: 50, + table_id: '1', + } }), + }, + }); + + const isInitialLoading = useIsInitialLoading(isPlaceholderData); + + const searchInput = ( + + ); + + const actionBar = ( + + { searchInput } + + + ); + + const content = data?.items ? ( + <> + + + + + { data.items.map((item, index) => ( + + )) } + + + ) : null; + + return ( + + ); +}; + +export default AddressMudTables; diff --git a/ui/address/mud/AddressMudTablesListItem.tsx b/ui/address/mud/AddressMudTablesListItem.tsx new file mode 100644 index 0000000000..5d2d875eee --- /dev/null +++ b/ui/address/mud/AddressMudTablesListItem.tsx @@ -0,0 +1,110 @@ +import { Divider, Text, Skeleton, useBoolean, Flex, Link, VStack, chakra, Box, Grid, GridItem } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { AddressMudTableItem } from 'types/api/address'; + +import { route } from 'nextjs-routes'; + +import Tag from 'ui/shared/chakra/Tag'; +import HashStringShorten from 'ui/shared/HashStringShorten'; +import IconSvg from 'ui/shared/IconSvg'; +import LinkInternal from 'ui/shared/links/LinkInternal'; +import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; + +type Props = { + item: AddressMudTableItem; + isLoading: boolean; + scrollRef?: React.RefObject; + hash: string; +}; + +const AddressMudTablesListItem = ({ item, isLoading, scrollRef, hash }: Props) => { + const [ isOpened, setIsOpened ] = useBoolean(false); + + const router = useRouter(); + + const onTableClick = React.useCallback((e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey) { + // Allow opening in a new tab/window with right-click or ctrl/cmd+click + return; + } + + e.preventDefault(); + + router.push( + { pathname: '/address/[hash]', query: { hash, tab: 'mud', table_id: e.currentTarget.getAttribute('data-id') as string } }, + undefined, + { shallow: true }, + ); + scrollRef?.current?.scrollIntoView(); + }, [ router, scrollRef, hash ]); + + return ( + + + + + + + + + + + + { item.table.table_full_name } + + + + { item.table.table_type } + + + + + + + + + { isOpened && ( + + { Boolean(item.schema.key_names.length) && ( + <> + Key + + { item.schema.key_names.map((name, index) => ( + + { item.schema.key_types[index] } { name } + + )) } + + + ) } + + Value + + { item.schema.value_names.map((name, index) => ( + + { item.schema.value_types[index] } { name } + + )) } + + + ) } + + ); +}; + +export default React.memo(AddressMudTablesListItem); diff --git a/ui/address/mud/AddressMudTablesTable.tsx b/ui/address/mud/AddressMudTablesTable.tsx new file mode 100644 index 0000000000..3ff787edd6 --- /dev/null +++ b/ui/address/mud/AddressMudTablesTable.tsx @@ -0,0 +1,45 @@ +import { Table, Tbody, Th, Tr } from '@chakra-ui/react'; +import React from 'react'; + +import type { AddressMudTables } from 'types/api/address'; + +import { default as Thead } from 'ui/shared/TheadSticky'; + +import AddressMudTablesTableItem from './AddressMudTablesTableItem'; + +type Props = { + items: AddressMudTables['items']; + isLoading: boolean; + top: number; + scrollRef?: React.RefObject; + hash: string; +} + +//sorry for the naming +const AddressMudTablesTable = ({ items, isLoading, top, scrollRef, hash }: Props) => { + return ( + + + + + + + + + + + { items.map((item, index) => ( + + )) } + +
Full nameTable IDType
+ ); +}; + +export default AddressMudTablesTable; diff --git a/ui/address/mud/AddressMudTablesTableItem.tsx b/ui/address/mud/AddressMudTablesTableItem.tsx new file mode 100644 index 0000000000..6e91290e9d --- /dev/null +++ b/ui/address/mud/AddressMudTablesTableItem.tsx @@ -0,0 +1,121 @@ +import { Td, Tr, Text, Skeleton, useBoolean, Link, Table, VStack, chakra } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { AddressMudTableItem } from 'types/api/address'; + +import { route } from 'nextjs-routes'; + +import Tag from 'ui/shared/chakra/Tag'; +import IconSvg from 'ui/shared/IconSvg'; +import LinkInternal from 'ui/shared/links/LinkInternal'; + +type Props = { + item: AddressMudTableItem; + isLoading: boolean; + scrollRef?: React.RefObject; + hash: string; +}; + +const AddressMudTablesTableItem = ({ item, isLoading, scrollRef, hash }: Props) => { + const [ isOpened, setIsOpened ] = useBoolean(false); + + const router = useRouter(); + + const onTableClick = React.useCallback((e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey) { + // Allow opening in a new tab/window with right-click or ctrl/cmd+click + return; + } + + e.preventDefault(); + + router.push( + { pathname: '/address/[hash]', query: { hash, tab: 'mud', table_id: e.currentTarget.getAttribute('data-id') as string } }, + undefined, + { shallow: true }, + ); + scrollRef?.current?.scrollIntoView(); + }, [ router, scrollRef, hash ]); + + return ( + <> + + + + + + + + + + + + { item.table.table_full_name } + + + + + + { item.table.table_id } + + + + + { item.table.table_type } + + + + { isOpened && ( + + + + + { Boolean(item.schema.key_names.length) && ( + + + + + ) } + + + + +
Key + + { item.schema.key_names.map((name, index) => ( + + { item.schema.key_types[index] } { name } + + )) } + +
Value + + { item.schema.value_names.map((name, index) => ( + + { item.schema.value_types[index] } { name } + + )) } + +
+ + + ) } + + ); +}; + +export default React.memo(AddressMudTablesTableItem); diff --git a/ui/address/mud/__screenshots__/AddressMudRecord.pw.tsx_default_base-view-1.png b/ui/address/mud/__screenshots__/AddressMudRecord.pw.tsx_default_base-view-1.png new file mode 100644 index 0000000000..55f2e12222 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudRecord.pw.tsx_default_base-view-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudRecord.pw.tsx_default_mobile-base-view-1.png b/ui/address/mud/__screenshots__/AddressMudRecord.pw.tsx_default_mobile-base-view-1.png new file mode 100644 index 0000000000..f9ff22f598 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudRecord.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_base-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_base-view-mobile-1.png new file mode 100644 index 0000000000..127a14f829 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_empty-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_empty-mobile-1.png new file mode 100644 index 0000000000..aa2bd1d9c5 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_empty-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_expanded-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_expanded-view-mobile-1.png new file mode 100644 index 0000000000..26633067f0 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_expanded-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_base-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_base-view-mobile-1.png new file mode 100644 index 0000000000..c64a84dd47 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_empty-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_empty-mobile-1.png new file mode 100644 index 0000000000..f70838b411 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_empty-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_expanded-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_expanded-view-mobile-1.png new file mode 100644 index 0000000000..89879a7a1c Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_expanded-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_default_base-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_default_base-view-mobile-1.png new file mode 100644 index 0000000000..f62b1d4dbf Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_default_with-schema-opened-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_default_with-schema-opened-mobile-1.png new file mode 100644 index 0000000000..7d81eab310 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_default_with-schema-opened-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_mobile_base-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_mobile_base-view-mobile-1.png new file mode 100644 index 0000000000..faabaccd35 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_mobile_with-schema-opened-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_mobile_with-schema-opened-mobile-1.png new file mode 100644 index 0000000000..67988657b1 Binary files /dev/null and b/ui/address/mud/__screenshots__/AddressMudTables.pw.tsx_mobile_with-schema-opened-mobile-1.png differ diff --git a/ui/address/mud/utils.ts b/ui/address/mud/utils.ts new file mode 100644 index 0000000000..259926daac --- /dev/null +++ b/ui/address/mud/utils.ts @@ -0,0 +1,18 @@ +import capitalizeFirstLetter from 'lib/capitalizeFirstLetter'; + +export const SORT_SEQUENCE: Record<'key0' | 'key1', Array<'desc' | 'asc' | undefined>> = { + key0: [ 'desc', 'asc', undefined ], + key1: [ 'desc', 'asc', undefined ], +}; + +export const getNameTypeText = (name: string, type: string) => { + return capitalizeFirstLetter(name) + ' (' + type + ')'; +}; + +export const getValueString = (value: string | Array) => { + if (Array.isArray(value)) { + return value.join(', '); + } + + return value.toString(); +}; diff --git a/ui/mudWorlds/MudWorldsListItem.tsx b/ui/mudWorlds/MudWorldsListItem.tsx new file mode 100644 index 0000000000..94ec895716 --- /dev/null +++ b/ui/mudWorlds/MudWorldsListItem.tsx @@ -0,0 +1,49 @@ +import { HStack, Skeleton } from '@chakra-ui/react'; +import BigNumber from 'bignumber.js'; +import React from 'react'; + +import type { MudWorldItem } from 'types/api/mudWorlds'; + +import config from 'configs/app'; +import { currencyUnits } from 'lib/units'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; + +type Props = { + item: MudWorldItem; + isLoading?: boolean; +} + +const MudWorldsListItem = ({ + item, + isLoading, +}: Props) => { + + const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** config.chain.currency.decimals)); + + return ( + + + + { `Balance ${ currencyUnits.ether }` } + + { addressBalance.dp(8).toFormat() } + + + + Txn count + + { Number(item.tx_count).toLocaleString() } + + + + ); +}; + +export default React.memo(MudWorldsListItem); diff --git a/ui/mudWorlds/MudWorldsTable.tsx b/ui/mudWorlds/MudWorldsTable.tsx new file mode 100644 index 0000000000..9e3417911e --- /dev/null +++ b/ui/mudWorlds/MudWorldsTable.tsx @@ -0,0 +1,40 @@ +import { Table, Tbody, Th, Tr } from '@chakra-ui/react'; +import React from 'react'; + +import type { MudWorldItem } from 'types/api/mudWorlds'; + +import { currencyUnits } from 'lib/units'; +import { default as Thead } from 'ui/shared/TheadSticky'; + +import MudWorldsTableItem from './MudWorldsTableItem'; + +type Props = { + items: Array; + top: number; + isLoading?: boolean; +} + +const MudWorldsTable = ({ items, top, isLoading }: Props) => { + return ( + + + + + + + + + + { items.map((item, index) => ( + + )) } + +
Address{ `Balance ${ currencyUnits.ether }` }Txn count
+ ); +}; + +export default MudWorldsTable; diff --git a/ui/mudWorlds/MudWorldsTableItem.tsx b/ui/mudWorlds/MudWorldsTableItem.tsx new file mode 100644 index 0000000000..dac871ffb2 --- /dev/null +++ b/ui/mudWorlds/MudWorldsTableItem.tsx @@ -0,0 +1,37 @@ +import { Text, Td, Tr, Skeleton } from '@chakra-ui/react'; +import BigNumber from 'bignumber.js'; +import React from 'react'; + +import type { MudWorldItem } from 'types/api/mudWorlds'; + +import config from 'configs/app'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; + +type Props = { item: MudWorldItem; isLoading?: boolean }; + +const MudWorldsTableItem = ({ item, isLoading }: Props) => { + const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** config.chain.currency.decimals)); + const addressBalanceChunks = addressBalance.dp(8).toFormat().split('.'); + + return ( + + + + + + + { addressBalanceChunks[0] } + { addressBalanceChunks[1] && . } + { addressBalanceChunks[1] } + + + + + { Number(item.tx_count).toLocaleString() } + + + + ); +}; + +export default MudWorldsTableItem; diff --git a/ui/nameDomains/NameDomainsListItem.tsx b/ui/nameDomains/NameDomainsListItem.tsx index 09d284fb6a..137e1caea9 100644 --- a/ui/nameDomains/NameDomainsListItem.tsx +++ b/ui/nameDomains/NameDomainsListItem.tsx @@ -42,7 +42,7 @@ const NameDomainsListItem = ({ Registered on -
{ dayjs(registrationDate).format('MMM DD YYYY HH:mm:ss A') }
+
{ dayjs(registrationDate).format('lll') }
{ dayjs(registrationDate).fromNow() }
@@ -54,7 +54,7 @@ const NameDomainsListItem = ({ Expiration date -
{ dayjs(expiryDate).format('MMM DD YYYY HH:mm:ss A') }
+
{ dayjs(expiryDate).format('lll') }
diff --git a/ui/nameDomains/NameDomainsTableItem.tsx b/ui/nameDomains/NameDomainsTableItem.tsx index ce5705fac3..35fa7b0a46 100644 --- a/ui/nameDomains/NameDomainsTableItem.tsx +++ b/ui/nameDomains/NameDomainsTableItem.tsx @@ -32,7 +32,7 @@ const NameDomainsTableItem = ({ { registrationDate && ( - { dayjs(registrationDate).format('MMM DD YYYY HH:mm:ss A') } + { dayjs(registrationDate).format('lll') } { dayjs(registrationDate).fromNow() } ) } @@ -40,7 +40,7 @@ const NameDomainsTableItem = ({ { expiryDate && ( - { dayjs(expiryDate).format('MMM DD YYYY HH:mm:ss A') } + { dayjs(expiryDate).format('lll') } ) } diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 075ba7144e..a3464f475b 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -23,6 +23,7 @@ import AddressContract from 'ui/address/AddressContract'; import AddressDetails from 'ui/address/AddressDetails'; import AddressInternalTxs from 'ui/address/AddressInternalTxs'; import AddressLogs from 'ui/address/AddressLogs'; +import AddressMud from 'ui/address/AddressMud'; import AddressTokens from 'ui/address/AddressTokens'; import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; import AddressTxs from 'ui/address/AddressTxs'; @@ -77,6 +78,14 @@ const AddressPageContent = () => { }, }); + const mudTablesCountQuery = useApiQuery('address_mud_tables_count', { + pathParams: { hash }, + queryOptions: { + enabled: config.features.mudFramework.isEnabled && areQueriesEnabled && Boolean(hash), + placeholderData: 10, + }, + }); + const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]); const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery, areQueriesEnabled); @@ -98,7 +107,10 @@ const AddressPageContent = () => { undefined; const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData); - const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData; + const isTabsLoading = + isLoading || + addressTabsCountersQuery.isPlaceholderData || + (config.features.mudFramework.isEnabled && mudTablesCountQuery.isPlaceholderData); const handleFetchedBytecodeMessage = React.useCallback(() => { addressQuery.refetch(); @@ -121,6 +133,12 @@ const AddressPageContent = () => { const tabs: Array = React.useMemo(() => { return [ + config.features.mudFramework.isEnabled && mudTablesCountQuery.data && mudTablesCountQuery.data > 0 && { + id: 'mud', + title: 'MUD', + count: mudTablesCountQuery.data, + component: , + }, { id: 'txs', title: 'Transactions', @@ -215,7 +233,15 @@ const AddressPageContent = () => { subTabs: contractTabs.tabs.map(tab => tab.id), } : undefined, ].filter(Boolean); - }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading, areQueriesEnabled ]); + }, [ + addressQuery.data, + contractTabs, + addressTabsCountersQuery.data, + userOpsAccountQuery.data, + isTabsLoading, + areQueriesEnabled, + mudTablesCountQuery.data, + ]); const tags: Array = React.useMemo(() => { return [ @@ -229,10 +255,13 @@ const AddressPageContent = () => { config.features.userOps.isEnabled && userOpsAccountQuery.data ? { slug: 'user_ops_acc', name: 'Smart contract wallet', tagType: 'custom' as const, ordinal: -10 } : undefined, + config.features.mudFramework.isEnabled && mudTablesCountQuery.data ? + { slug: 'mud', name: 'MUD World', 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 ]); + }, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data, mudTablesCountQuery.data ]); const titleContentAfter = ( { ; const backLink = React.useMemo(() => { - const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts'); + if (appProps.referrer && appProps.referrer.includes('/accounts')) { + return { + label: 'Back to top accounts list', + url: appProps.referrer, + }; + } - if (!hasGoBackLink) { - return; + if (appProps.referrer && appProps.referrer.includes('/mud-worlds')) { + return { + label: 'Back to MUD worlds list', + url: appProps.referrer, + }; } - return { - label: 'Back to top accounts list', - url: appProps.referrer, - }; + return; }, [ appProps.referrer ]); const titleSecondRow = ( diff --git a/ui/pages/MudWorlds.pw.tsx b/ui/pages/MudWorlds.pw.tsx new file mode 100644 index 0000000000..f89ac27ef6 --- /dev/null +++ b/ui/pages/MudWorlds.pw.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { mudWorlds } from 'mocks/mud/mudWorlds'; +import { test, expect } from 'playwright/lib'; + +import MudWorlds from './MudWorlds'; + +test('default view +@mobile', async({ mockTextAd, mockApiResponse, render }) => { + await mockTextAd(); + await mockApiResponse('mud_worlds', mudWorlds); + const component = await render(); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/MudWorlds.tsx b/ui/pages/MudWorlds.tsx new file mode 100644 index 0000000000..4c77534325 --- /dev/null +++ b/ui/pages/MudWorlds.tsx @@ -0,0 +1,68 @@ +import { Hide, Show } from '@chakra-ui/react'; +import React from 'react'; + +import { MUD_WORLD } from 'stubs/mud'; +import { generateListStub } from 'stubs/utils'; +import MudWorldsListItem from 'ui/mudWorlds/MudWorldsListItem'; +import MudWorldsTable from 'ui/mudWorlds/MudWorldsTable'; +import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import PageTitle from 'ui/shared/Page/PageTitle'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; + +const MudWorlds = () => { + const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({ + resourceName: 'mud_worlds', + options: { + placeholderData: generateListStub<'mud_worlds'>( + MUD_WORLD, + 50, + { + next_page_params: { + items_count: 50, + world: '1', + }, + }, + ), + }, + }); + + const content = data?.items ? ( + <> + + { data.items.map(((item, index) => ( + + ))) } + + + + + + ) : null; + + const actionBar = pagination.isVisible ? ( + + + + ) : null; + + return ( + <> + + + + ); +}; + +export default MudWorlds; diff --git a/ui/pages/__screenshots__/MudWorlds.pw.tsx_default_default-view-mobile-1.png b/ui/pages/__screenshots__/MudWorlds.pw.tsx_default_default-view-mobile-1.png new file mode 100644 index 0000000000..fbc8a9c140 Binary files /dev/null and b/ui/pages/__screenshots__/MudWorlds.pw.tsx_default_default-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/MudWorlds.pw.tsx_mobile_default-view-mobile-1.png b/ui/pages/__screenshots__/MudWorlds.pw.tsx_mobile_default-view-mobile-1.png new file mode 100644 index 0000000000..e698756aab Binary files /dev/null and b/ui/pages/__screenshots__/MudWorlds.pw.tsx_mobile_default-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png b/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png index 4aa9eaba13..dc04ae98fb 100644 Binary files a/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png and b/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png b/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png index 90abdecd57..300b6b73d3 100644 Binary files a/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png and b/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png differ diff --git a/ui/shared/CopyToClipboard.tsx b/ui/shared/CopyToClipboard.tsx index 3ee4431948..f241913760 100644 --- a/ui/shared/CopyToClipboard.tsx +++ b/ui/shared/CopyToClipboard.tsx @@ -9,9 +9,10 @@ export interface Props { isLoading?: boolean; onClick?: (event: React.MouseEvent) => void; size?: number; + type?: 'link'; } -const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5 }: Props) => { +const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type }: 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 @@ -36,10 +37,10 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5 }: Prop } return ( - + } + icon={ } boxSize={ size } color={ iconColor } variant="simple" diff --git a/ui/shared/DataListDisplay.tsx b/ui/shared/DataListDisplay.tsx index 550ab47912..8e230559d2 100644 --- a/ui/shared/DataListDisplay.tsx +++ b/ui/shared/DataListDisplay.tsx @@ -13,8 +13,9 @@ type FilterProps = { type Props = { isError: boolean; items?: Array; - emptyText: string; + emptyText: string | React.ReactNode; actionBar?: React.ReactNode; + showActionBarIfEmpty?: boolean; content: React.ReactNode; className?: string; filterProps?: FilterProps; @@ -35,7 +36,12 @@ const DataListDisplay = (props: Props) => { } if (!props.items?.length) { - return props.emptyText ? { props.emptyText } : null; + return ( + <> + { props.showActionBarIfEmpty && props.actionBar } + { props.emptyText && { props.emptyText } } + + ); } return ( diff --git a/ui/shared/filters/TableColumnFilter.tsx b/ui/shared/filters/TableColumnFilter.tsx new file mode 100644 index 0000000000..f552c401f2 --- /dev/null +++ b/ui/shared/filters/TableColumnFilter.tsx @@ -0,0 +1,82 @@ +import { + chakra, + Flex, + Text, + Link, + Button, +} from '@chakra-ui/react'; +import React from 'react'; + +import TableColumnFilterWrapper from './TableColumnFilterWrapper'; + +type Props = { + columnName: string; + title: string; + isActive?: boolean; + isFilled?: boolean; + onFilter: () => void; + onReset?: () => void; + onClose?: () => void; + isLoading?: boolean; + className?: string; + children: React.ReactNode; +} + +type ContentProps = { + title: string; + isFilled?: boolean; + hasReset?: boolean; + onFilter: () => void; + onReset?: () => void; + onClose?: () => void; + children: React.ReactNode; +} + +const TableColumnFilterContent = ({ title, isFilled, hasReset, onFilter, onReset, onClose, children }: ContentProps) => { + const onFilterClick = React.useCallback(() => { + onClose && onClose(); + onFilter(); + }, [ onClose, onFilter ]); + return ( + <> + + { title } + { hasReset && ( + + Reset + + ) } + + { children } + + + ); +}; + +const TableColumnFilter = ({ columnName, isActive, className, isLoading, ...props }: Props) => { + return ( + + + + ); +}; + +export default chakra(TableColumnFilter); diff --git a/ui/shared/filters/TableColumnFilterWrapper.tsx b/ui/shared/filters/TableColumnFilterWrapper.tsx new file mode 100644 index 0000000000..bc65b7682b --- /dev/null +++ b/ui/shared/filters/TableColumnFilterWrapper.tsx @@ -0,0 +1,59 @@ +import { + PopoverTrigger, + PopoverContent, + PopoverBody, + useDisclosure, + IconButton, + chakra, +} from '@chakra-ui/react'; +import React from 'react'; + +import Popover from 'ui/shared/chakra/Popover'; +import IconSvg from 'ui/shared/IconSvg'; + +interface Props { + columnName: string; + isActive?: boolean; + isLoading?: boolean; + className?: string; + children: React.ReactNode; +} + +const TableColumnFilterWrapper = ({ columnName, isActive, className, children, isLoading }: Props) => { + const { isOpen, onToggle, onClose } = useDisclosure(); + + const child = React.Children.only(children) as React.ReactElement & { + ref?: React.Ref; + }; + + const modifiedChildren = React.cloneElement( + child, + { onClose }, + ); + + return ( + + + } + isActive={ isActive } + isDisabled={ isLoading } + borderRadius="4px" + color="text_secondary" + /> + + + + { modifiedChildren } + + + + ); +}; + +export default chakra(TableColumnFilterWrapper); diff --git a/ui/shared/layout/components/Container.tsx b/ui/shared/layout/components/Container.tsx index bbd18975cd..653a712ba5 100644 --- a/ui/shared/layout/components/Container.tsx +++ b/ui/shared/layout/components/Container.tsx @@ -8,7 +8,7 @@ interface Props { const Container = ({ children, className }: Props) => { return ( - + { children } ); diff --git a/ui/shared/sort/getNextSortOrder.tsx b/ui/shared/sort/getNextSortOrder.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/shared/sort/getNextSortValue.ts b/ui/shared/sort/getNextSortValue.ts index 9e29662971..6d1fdeabc9 100644 --- a/ui/shared/sort/getNextSortValue.ts +++ b/ui/shared/sort/getNextSortValue.ts @@ -3,8 +3,20 @@ export default function getNextSortValue { const sequence = sortSequence[field]; + getNextValueFromSequence(sequence, prevValue); const curIndex = sequence.findIndex((sort) => sort === prevValue); const nextIndex = curIndex + 1 > sequence.length - 1 ? 0 : curIndex + 1; return sequence[nextIndex]; }; } + +export function getNextValueFromSequence(sequence: Array, prevValue: T) { + const curIndex = sequence.findIndex((val) => val === prevValue); + const nextIndex = curIndex + 1 > sequence.length - 1 ? 0 : curIndex + 1; + return sequence[nextIndex]; +} + +// asc desc undefined +type Order = 'asc' | 'desc' | undefined; +const sequence: Array = [ 'desc', 'asc', undefined ]; +export const getNextOrderValue = (getNextValueFromSequence).bind(undefined, sequence); diff --git a/ui/shared/sort/getSortParamsFromQuery.ts b/ui/shared/sort/getSortParamsFromQuery.ts new file mode 100644 index 0000000000..fd094917a9 --- /dev/null +++ b/ui/shared/sort/getSortParamsFromQuery.ts @@ -0,0 +1,23 @@ +import type { Query } from 'nextjs-routes'; + +import getQueryParamString from 'lib/router/getQueryParamString'; + +export default function getSortParamsFromQuery(query: Query, sortOptions: Record>) { + if (!query.sort || !query.order) { + return undefined; + } + + const sortStr = getQueryParamString(query.sort); + + if (!Object.keys(sortOptions).includes(sortStr)) { + return undefined; + } + + const orderStr = getQueryParamString(query.order); + + if (!sortOptions[sortStr].includes(orderStr)) { + return undefined; + } + + return ({ sort: sortStr, order: orderStr } as T); +} diff --git a/ui/shared/statusTag/StatusTag.tsx b/ui/shared/statusTag/StatusTag.tsx index 957aa5a806..7a01aa5a5c 100644 --- a/ui/shared/statusTag/StatusTag.tsx +++ b/ui/shared/statusTag/StatusTag.tsx @@ -1,6 +1,7 @@ import { TagLabel, Tooltip, chakra } from '@chakra-ui/react'; import React from 'react'; +import capitalizeFirstLetter from 'lib/capitalizeFirstLetter'; import Tag from 'ui/shared/chakra/Tag'; import type { IconName } from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg'; @@ -19,7 +20,7 @@ const StatusTag = ({ type, text, errorText, isLoading, className }: Props) => { let icon: IconName; let colorScheme; - const capitalizedText = text.charAt(0).toUpperCase() + text.slice(1); + const capitalizedText = capitalizeFirstLetter(text); switch (type) { case 'ok': diff --git a/ui/txs/noves/utils.ts b/ui/txs/noves/utils.ts index c5a986b66a..0197b38e7c 100644 --- a/ui/txs/noves/utils.ts +++ b/ui/txs/noves/utils.ts @@ -1,3 +1,5 @@ +import capitalizeFirstLetter from 'lib/capitalizeFirstLetter'; + export function camelCaseToSentence(camelCaseString: string | undefined) { if (!camelCaseString) { return ''; @@ -5,7 +7,7 @@ export function camelCaseToSentence(camelCaseString: string | undefined) { let sentence = camelCaseString.replace(/([a-z])([A-Z])/g, '$1 $2'); sentence = sentence.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2'); - sentence = sentence.charAt(0).toUpperCase() + sentence.slice(1); + sentence = capitalizeFirstLetter(sentence); return sentence; }