From d0f15d712fe856aea8861405be7407a07dee2e0d Mon Sep 17 00:00:00 2001 From: isstuev Date: Wed, 10 Jul 2024 21:39:01 +0200 Subject: [PATCH] MUD --- .github/workflows/deploy-review.yml | 1 + .vscode/tasks.json | 1 + configs/app/features/index.ts | 1 + configs/app/features/mudFramework.ts | 22 +++ configs/envs/.env.garnet | 49 ++++++ deploy/tools/envs-validator/schema.ts | 10 ++ docs/ENVS.md | 1 + icons/MUD.svg | 4 + lib/api/resources.ts | 44 +++++ lib/capitalizeFirstLetter.ts | 7 + lib/hooks/useNavItems.tsx | 7 + lib/metadata/getPageOgType.ts | 1 + lib/metadata/templates/description.ts | 1 + lib/metadata/templates/title.ts | 1 + lib/mixpanel/getPageType.ts | 1 + nextjs/getServerSideProps.ts | 10 ++ nextjs/nextjs-routes.d.ts | 1 + pages/mud-worlds.tsx | 19 +++ public/icons/name.d.ts | 1 + stubs/address.ts | 7 + stubs/mud.ts | 24 +++ types/api/address.ts | 54 +++++++ types/api/mudWorlds.ts | 30 ++++ ui/address/AddressMud.tsx | 37 +++++ .../contract/methods/ContractAbiItem.tsx | 25 +-- ui/address/mud/AddressMudBreadcrumbs.tsx | 86 ++++++++++ ui/address/mud/AddressMudRecord.tsx | 91 +++++++++++ ui/address/mud/AddressMudRecordsKeyFilter.tsx | 41 +++++ ui/address/mud/AddressMudRecordsTable.tsx | 152 ++++++++++++++++++ ui/address/mud/AddressMudTable.tsx | 149 +++++++++++++++++ ui/address/mud/AddressMudTables.tsx | 102 ++++++++++++ ui/address/mud/AddressMudTablesTable.tsx | 41 +++++ ui/address/mud/AddressMudTablesTableItem.tsx | 102 ++++++++++++ ui/address/mud/utils.ts | 10 ++ ui/mudWorlds/MudWorldsListItem.tsx | 49 ++++++ ui/mudWorlds/MudWorldsTable.tsx | 40 +++++ ui/mudWorlds/MudWorldsTableItem.tsx | 43 +++++ ui/pages/Address.tsx | 32 +++- ui/pages/MudWorlds.tsx | 68 ++++++++ ui/shared/CopyToClipboard.tsx | 7 +- ui/shared/filters/TableColumnFilter.tsx | 82 ++++++++++ .../filters/TableColumnFilterWrapper.tsx | 59 +++++++ ui/shared/layout/components/Container.tsx | 2 +- ui/shared/sort/getNextSortOrder.tsx | 0 ui/shared/sort/getNextSortValue.ts | 12 ++ ui/shared/sort/getSortParamsFromQuery.ts | 23 +++ ui/shared/statusTag/StatusTag.tsx | 3 +- ui/txs/noves/utils.ts | 4 +- 48 files changed, 1526 insertions(+), 31 deletions(-) create mode 100644 configs/app/features/mudFramework.ts create mode 100644 configs/envs/.env.garnet create mode 100644 icons/MUD.svg create mode 100644 lib/capitalizeFirstLetter.ts create mode 100644 pages/mud-worlds.tsx create mode 100644 stubs/mud.ts create mode 100644 types/api/mudWorlds.ts create mode 100644 ui/address/AddressMud.tsx create mode 100644 ui/address/mud/AddressMudBreadcrumbs.tsx create mode 100644 ui/address/mud/AddressMudRecord.tsx create mode 100644 ui/address/mud/AddressMudRecordsKeyFilter.tsx create mode 100644 ui/address/mud/AddressMudRecordsTable.tsx create mode 100644 ui/address/mud/AddressMudTable.tsx create mode 100644 ui/address/mud/AddressMudTables.tsx create mode 100644 ui/address/mud/AddressMudTablesTable.tsx create mode 100644 ui/address/mud/AddressMudTablesTableItem.tsx create mode 100644 ui/address/mud/utils.ts create mode 100644 ui/mudWorlds/MudWorldsListItem.tsx create mode 100644 ui/mudWorlds/MudWorldsTable.tsx create mode 100644 ui/mudWorlds/MudWorldsTableItem.tsx create mode 100644 ui/pages/MudWorlds.tsx create mode 100644 ui/shared/filters/TableColumnFilter.tsx create mode 100644 ui/shared/filters/TableColumnFilterWrapper.tsx create mode 100644 ui/shared/sort/getNextSortOrder.tsx create mode 100644 ui/shared/sort/getSortParamsFromQuery.ts 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 2a1648ac2a..e7830b7d00 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -342,6 +342,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 b2ac272980..216d74824f 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -17,6 +17,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..c4c0c4c583 --- /dev/null +++ b/configs/envs/.env.garnet @@ -0,0 +1,49 @@ +# 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 +## 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 12c35f6392..42e64d30d3 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -659,6 +659,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 1d7cfe7829..2b63d21660 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -406,6 +406,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/lib/api/resources.ts b/lib/api/resources.ts index ee25a7ef09..bdf0041107 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', @@ -896,6 +931,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' | @@ -1052,6 +1088,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 */ @@ -1083,6 +1124,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 */ @@ -1095,5 +1138,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/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 608a6b6d0d..9090d6e01a 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', + 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 8d709db84e..65d3462017 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -46,6 +46,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 5effb299c7..89dbb89138 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -50,6 +50,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 c0934b13a8..3e4e9fcc09 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -46,6 +46,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 13a60c1dd8..24a00a28a1 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -44,6 +44,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/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 ed2eaa19c2..3aa2474b75 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -42,6 +42,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 e2567b4b77..7c658d34fb 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -86,6 +86,7 @@ | "monaco/vyper" | "moon-with-star" | "moon" + | "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..b42f6768c9 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/methods/ContractAbiItem.tsx b/ui/address/contract/methods/ContractAbiItem.tsx index b831f0a15b..90ee69042d 100644 --- a/ui/address/contract/methods/ContractAbiItem.tsx +++ b/ui/address/contract/methods/ContractAbiItem.tsx @@ -1,4 +1,4 @@ -import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react'; +import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, 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..29f26289c1 --- /dev/null +++ b/ui/address/mud/AddressMudBreadcrumbs.tsx @@ -0,0 +1,86 @@ +import { HStack, useColorModeValue, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import { route } from 'nextjs-routes'; + +import CopyToClipboard from 'ui/shared/CopyToClipboard'; +import IconSvg from 'ui/shared/IconSvg'; +import LinkInternal from 'ui/shared/links/LinkInternal'; + +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 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 }; + return ( + + + + + { ('recordId' in props) && ( + + ) } + + ); +}; + +export default React.memo(chakra(AddressMudBreadcrumbs)); diff --git a/ui/address/mud/AddressMudRecord.tsx b/ui/address/mud/AddressMudRecord.tsx new file mode 100644 index 0000000000..c8eb28efc1 --- /dev/null +++ b/ui/address/mud/AddressMudRecord.tsx @@ -0,0 +1,91 @@ +import { Box, Td, Tr, Flex, useColorModeValue, Table } 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'; + +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 valuesBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50'); + + 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) => ( + + + + + )) } + { data?.schema.value_names.length && ( + <> + + + + + + { data?.schema.value_names.map((valName, index) => ( + + + + + + )) } + + ) } +
+ { keyName } ({ data.schema.key_types[index] }) + + + + { index === 0 && { dayjs(data.record.timestamp).format('llll') } } + +
FieldTypeValue
{ valName }{ data.schema.value_types[index] }{ data.record.decoded[valName] }
+ + ); +}; + +export default AddressMudRecord; 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..771d13e699 --- /dev/null +++ b/ui/address/mud/AddressMudRecordsTable.tsx @@ -0,0 +1,152 @@ +import type { StyleProps } from '@chakra-ui/react'; +import { Box, Link, Table, Tbody, Td, Th, Tr, Flex, useColorModeValue, useBoolean } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { AddressMudRecords, AddressMudRecordsFilter, AddressMudRecordsSorting } from 'types/api/address'; + +import capitalizeFirstLetter from 'lib/capitalizeFirstLetter'; +import dayjs from 'lib/date/dayjs'; +import IconSvg from 'ui/shared/IconSvg'; +import { default as Thead } from 'ui/shared/TheadSticky'; + +import AddressMudRecordsKeyFilter from './AddressMudRecordsKeyFilter'; +import { getNameTypeText } from './utils'; + +const COL_MIN_WIDTH = 180; +const CUT_COL_WIDTH = 36; + +type Props = { + data: AddressMudRecords; + top: number; + sorting?: AddressMudRecordsSorting; + toggleSorting: (key: AddressMudRecordsSorting['sort']) => void; + setFilters: React.Dispatch>; + filters: AddressMudRecordsFilter; +} + +const AddressMudRecordsTable = ({ data, top, sorting, toggleSorting, filters, setFilters }: Props) => { + const [ colsCutCount, setColsCutCount ] = React.useState(0); + const [ isOpened, setIsOpened ] = useBoolean(false); + const [ hasCut, setHasCut ] = useBoolean(true); + + const tableRef = React.useRef(null); + + const router = useRouter(); + const onRecordClick = React.useCallback((e: React.MouseEvent) => { + const newQuery = { + ...router.query, + record_id: e.currentTarget.getAttribute('data-id') as string, + }; + router.push({ pathname: router.pathname, query: newQuery }, undefined, { shallow: true }); + }, [ router ]); + + 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 && tableRef.current) { + const count = Math.floor((tableRef.current.getBoundingClientRect().width - CUT_COL_WIDTH) / COL_MIN_WIDTH); + const total = data.schema.key_names.length + data.schema.value_names.length; + if (total > 2 && count - 1 < total) { + setColsCutCount(count - 1); + } else { + setHasCut.off(); + } + } + }, [ colsCutCount, data.schema, hasCut, setHasCut ]); + + const cutWidth = `${ CUT_COL_WIDTH }px `; + + const tdStyles: StyleProps = { + wordBreak: 'break-all', + whiteSpace: 'normal', + minW: `${ COL_MIN_WIDTH }px`, + w: `${ COL_MIN_WIDTH }px`, + }; + + const thStyles: StyleProps = { + wordBreak: 'break-word', + whiteSpace: 'normal', + minW: `${ COL_MIN_WIDTH }px`, + w: `${ COL_MIN_WIDTH }px`, + }; + + 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); + + return ( + + + + + { keys.map((keyName, index) => { + const text = getNameTypeText(keyName, data.schema.key_types[index]); + return ( + + ); + }) } + { values.map((valName, index) => ( + + )) } + { hasCut && !isOpened && } + + { hasCut && isOpened && } + + + + { 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 ? + { item.decoded[keyName].toString() } : + item.decoded[keyName].toString() + } + { item.decoded[valName].toString() }{ dayjs(item.timestamp).format('llll') }
+
+ ); +}; + +export default AddressMudRecordsTable; diff --git a/ui/address/mud/AddressMudTable.tsx b/ui/address/mud/AddressMudTable.tsx new file mode 100644 index 0000000000..8504444b53 --- /dev/null +++ b/ui/address/mud/AddressMudTable.tsx @@ -0,0 +1,149 @@ +import { Box, HStack, Hide, Show, Tag, TagCloseButton, chakra } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { AddressMudRecordsFilter, AddressMudRecordsSorting } from 'types/api/address'; + +import { apos, nbsp } from 'lib/html-entities'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import ActionBar 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 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; + }); + }, []); + + if (isLoading) { + return ; + } + + const hasActiveFilters = Object.values(filters).some(Boolean); + + 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 actionBar = ( + + + { data && ( + + ) } + { filtersTags } + + + + ); + + const content = data?.items ? ( + <> + + + + + waiting for mobile mockup + { /* { data.items.map((item, index) => ( + + )) } */ } + + + ) : null; + + return ( + + ); +}; + +export default AddressMudTable; diff --git a/ui/address/mud/AddressMudTables.tsx b/ui/address/mud/AddressMudTables.tsx new file mode 100644 index 0000000000..16092ddf6f --- /dev/null +++ b/ui/address/mud/AddressMudTables.tsx @@ -0,0 +1,102 @@ +import { Hide, Show } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +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 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 { data, isPlaceholderData, isError, pagination } = useQueryWithPages({ + resourceName: 'address_mud_tables', + pathParams: { hash }, + filters: { q: searchTerm }, + 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 ? ( + <> + + + + + waiting for mobile mockup + { /* { data.items.map((item, index) => ( + + )) } */ } + + + ) : null; + + return ( + + ); +}; + +export default AddressMudTables; diff --git a/ui/address/mud/AddressMudTablesTable.tsx b/ui/address/mud/AddressMudTablesTable.tsx new file mode 100644 index 0000000000..2c439a0f41 --- /dev/null +++ b/ui/address/mud/AddressMudTablesTable.tsx @@ -0,0 +1,41 @@ +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; +} + +//sorry for the naming +const AddressMudTablesTable = ({ items, isLoading, top }: 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..a2ac25a4e8 --- /dev/null +++ b/ui/address/mud/AddressMudTablesTableItem.tsx @@ -0,0 +1,102 @@ +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 Tag from 'ui/shared/chakra/Tag'; +import IconSvg from 'ui/shared/IconSvg'; + +type Props = { + item: AddressMudTableItem; + isLoading: boolean; +}; + +const AddressMudTablesTableItem = ({ item, isLoading }: Props) => { + const [ isOpened, setIsOpened ] = useBoolean(false); + + const router = useRouter(); + + const onTableClick = React.useCallback((e: React.MouseEvent) => { + const newQuery = { + ...router.query, + table_id: e.currentTarget.getAttribute('data-id') as string, + }; + router.push({ pathname: router.pathname, query: newQuery }, undefined, { shallow: true }); + }, [ router ]); + + 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/utils.ts b/ui/address/mud/utils.ts new file mode 100644 index 0000000000..a731c3d70a --- /dev/null +++ b/ui/address/mud/utils.ts @@ -0,0 +1,10 @@ +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 + ')'; +}; 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..91549d3a2e --- /dev/null +++ b/ui/mudWorlds/MudWorldsTableItem.tsx @@ -0,0 +1,43 @@ +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'; + +const mudFrameworkFeature = config.features.mudFramework; + +type Props = { item: MudWorldItem; isLoading?: boolean }; + +const MudWorldsTableItem = ({ item, isLoading }: Props) => { + if (!mudFrameworkFeature.isEnabled) { + return null; + } + + 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/pages/Address.tsx b/ui/pages/Address.tsx index 075ba7144e..a517e055de 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,7 @@ const AddressPageContent = () => { undefined; const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData); - const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData; + const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData || mudTablesCountQuery.isPlaceholderData; const handleFetchedBytecodeMessage = React.useCallback(() => { addressQuery.refetch(); @@ -121,6 +130,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 +230,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 +252,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 { 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/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/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..c4fbdef1c7 --- /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; }