Commit c4ae053b authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #2090 from blockscout/mud

MUD
parents 03c1bd4d 2a0bc74f
......@@ -13,6 +13,7 @@ on:
- arbitrum
- base
- celo_alfajores
- garnet
- gnosis
- eth
- eth_sepolia
......
......@@ -362,6 +362,7 @@
"arbitrum",
"base",
"celo_alfajores",
"garnet",
"gnosis",
"eth",
"eth_goerli",
......
......@@ -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';
......
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;
# 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
......@@ -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(),
......
......@@ -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` | - |
&nbsp;
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" d="M3.933 17.138a3.98 3.98 0 0 1-1.03-.136 4.001 4.001 0 0 1-2.771-4.866L1.796 5.82a3.958 3.958 0 0 1 1.85-2.45 3.88 3.88 0 0 1 2.966-.386A3.944 3.944 0 0 1 9.07 4.992h1.857a3.922 3.922 0 0 1 2.458-2.008 3.88 3.88 0 0 1 2.965.386 3.977 3.977 0 0 1 1.85 2.436l1.665 6.33a4 4 0 0 1-2.779 4.88 3.879 3.879 0 0 1-2.965-.386 3.959 3.959 0 0 1-1.85-2.436l-.13-.629H7.856l-.164.615a3.958 3.958 0 0 1-1.85 2.436 3.9 3.9 0 0 1-1.908.522ZM5.59 4.278a2.494 2.494 0 0 0-1.229.328A2.572 2.572 0 0 0 3.175 6.17l-1.664 6.316a2.572 2.572 0 0 0 1.771 3.137 2.45 2.45 0 0 0 1.872-.243 2.53 2.53 0 0 0 1.186-1.565l.443-1.679h6.43l.436 1.68a2.558 2.558 0 0 0 1.187 1.564 2.47 2.47 0 0 0 1.879.243 2.558 2.558 0 0 0 1.764-3.137L16.814 6.17a2.535 2.535 0 0 0-1.178-1.564 2.472 2.472 0 0 0-1.88-.243 2.536 2.536 0 0 0-1.693 1.586l-.171.472H8.105l-.171-.472a2.544 2.544 0 0 0-1.7-1.586 2.486 2.486 0 0 0-.644-.086Z"/>
<path fill="currentColor" d="M5.712 9.993a1.429 1.429 0 1 1 0-2.858 1.429 1.429 0 0 1 0 2.858Zm8.573-2.143a.714.714 0 1 0 0-1.43.714.714 0 0 0 0 1.43Zm0 2.857a.714.714 0 1 0 0-1.429.714.714 0 0 0 0 1.43Zm-1.428-1.429a.714.714 0 1 0 0-1.428.714.714 0 0 0 0 1.428Zm2.857 0a.714.714 0 1 0 0-1.428.714.714 0 0 0 0 1.428Z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" d="M6.485 23.997a4.23 4.23 0 0 0 1.234.183 4.31 4.31 0 0 0 2.29-.7c.545-.353 1.022-.824 1.403-1.386s.66-1.202.817-1.884l.197-.825h5.144l.155.844c.157.682.435 1.323.816 1.885s.859 1.032 1.404 1.385a4.334 4.334 0 0 0 1.733.658 4.18 4.18 0 0 0 1.825-.14c1.214-.384 2.244-1.285 2.868-2.51a5.933 5.933 0 0 0 .467-4.04l-1.997-8.496c-.16-.682-.437-1.322-.818-1.883s-.858-1.033-1.403-1.387a4.333 4.333 0 0 0-1.733-.657 4.18 4.18 0 0 0-1.825.14c-.632.194-1.221.534-1.73.998s-.923 1.042-1.22 1.696h-2.228c-.298-.653-.714-1.23-1.222-1.694s-1.096-.804-1.728-1a4.18 4.18 0 0 0-1.825-.14c-.61.088-1.2.312-1.733.657A4.969 4.969 0 0 0 5.97 7.095a5.602 5.602 0 0 0-.813 1.895l-1.998 8.477a5.932 5.932 0 0 0 .472 4.025c.621 1.22 1.646 2.12 2.855 2.505zM8.234 7.36a2.752 2.752 0 0 1 1.474-.441c.26 0 .52.04.772.115.467.148.895.418 1.25.788.355.37.626.83.79 1.34l.206.634h4.544l.206-.633c.163-.51.432-.97.786-1.34s.78-.64 1.246-.789c.377-.114.77-.144 1.156-.088.387.056.76.197 1.099.414.348.228.653.53.896.891.243.36.419.772.518 1.21l1.998 8.476c.214.872.112 1.804-.285 2.592s-1.055 1.37-1.833 1.618c-.376.114-.77.144-1.156.088a2.761 2.761 0 0 1-1.098-.414c-.35-.228-.655-.531-.9-.892s-.422-.77-.524-1.208l-.523-2.254H11.14l-.532 2.254c-.1.438-.277.85-.522 1.21-.244.361-.55.663-.9.89a2.738 2.738 0 0 1-1.095.414 2.64 2.64 0 0 1-1.152-.088c-.777-.25-1.435-.832-1.832-1.62a3.815 3.815 0 0 1-.294-2.59L6.81 9.46c.208-.883.72-1.638 1.424-2.1zm12.765 3.985a.857.857 0 1 1-1.714 0 .857.857 0 0 1 1.714 0zm-12.098 3.14a1.715 1.715 0 1 0 1.906-2.85 1.715 1.715 0 0 0-1.906 2.85zm11.241 1.147a.857.857 0 1 0 0-1.714.857.857 0 0 0 0 1.714zm-.857-2.572a.857.857 0 1 1-1.714 0 .857.857 0 0 1 1.714 0zm2.572.857a.857.857 0 1 0 0-1.714.857.857 0 0 0 0 1.714z" fill-rule="evenodd" clip-rule="evenodd"/>
</svg>
......@@ -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 */
export default function capitalizeFirstLetter(text: string) {
if (!text || !text.length) {
return '';
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
......@@ -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',
......
......@@ -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,
......
......@@ -48,6 +48,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/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',
......
......@@ -52,6 +52,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/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,
......
......@@ -48,6 +48,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/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',
......
......@@ -46,6 +46,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/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',
......
/* 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,
};
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',
},
};
......@@ -275,3 +275,13 @@ export const disputeGames: GetServerSideProps<Props> = async(context) => {
return base(context);
};
export const mud: GetServerSideProps<Props> = async(context) => {
if (!config.features.mudFramework.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
......@@ -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 }>
......
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 (
<PageNextJs pathname="/mud-worlds">
<MudWorlds/>
</PageNextJs>
);
};
export default Page;
export { mud as getServerSideProps } from 'nextjs/getServerSideProps';
......@@ -91,6 +91,8 @@
| "monaco/vyper"
| "moon-with-star"
| "moon"
| "MUD_menu"
| "MUD"
| "networks"
| "networks/icon-placeholder"
| "networks/logo-placeholder"
......
......@@ -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,
};
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,
};
......@@ -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<AddressMudTableItem>;
next_page_params: {
items_count: number;
table_id: string;
};
}
export type AddressMudTablesFilter = {
q?: string;
}
export type AddressMudRecords = {
items: Array<AddressMudRecordsItem>;
schema: MudWorldSchema;
table: MudWorldTable;
next_page_params: {
items_count: number;
key0: string;
key1: string;
key_bytes: string;
};
}
export type AddressMudRecordsItem = {
decoded: Record<string, string | Array<string>>;
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;
}
import type { AddressParam } from './addressParams';
export type MudWorldsResponse = {
items: Array<MudWorldItem>;
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<string>;
key_types: Array<string>;
value_names: Array<string>;
value_types: Array<string>;
};
export type MudWorldTable = {
table_full_name: string;
table_id: string;
table_name: string;
table_namespace: string;
table_type: string;
}
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<HTMLDivElement>;
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 <AddressMudRecord tableId={ tableId } recordId={ recordId } isQueryEnabled={ isQueryEnabled } scrollRef={ scrollRef }/>;
}
if (tableId) {
return <AddressMudTable tableId={ tableId } scrollRef={ scrollRef } isQueryEnabled={ isQueryEnabled }/>;
}
return <AddressMudTables scrollRef={ scrollRef } isQueryEnabled={ isQueryEnabled }/>;
};
export default AddressMud;
......@@ -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 } });
});
......
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)
<>
<Element as="h2" name={ 'method_id' in data ? getElementName(data.method_id) : '' }>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
{ 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ methodIdTooltip.isOpen || hasCopied } onClose={ methodIdTooltip.onClose }>
<Box
boxSize={ 5 }
color="text_secondary"
_hover={{ color: 'link_hovered' }}
mr={ 2 }
onClick={ handleCopyLinkClick }
onMouseEnter={ methodIdTooltip.onOpen }
onMouseLeave={ methodIdTooltip.onClose }
>
<IconSvg name="link" boxSize={ 5 }/>
</Box>
</Tooltip>
) }
{ 'method_id' in data && <CopyToClipboard text={ url } onClick={ handleCopyLinkClick } type="link" mr={ 2 } ml={ 0 } color="text_secondary"/> }
<Box as="span" fontWeight={ 500 } mr={ 1 }>
{ index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name }
</Box>
......
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<HTMLDivElement>;
className?: string;
hash: string;
tableId: string;
tableName: string;
}
type RecordViewProps = TableViewProps & {
recordId: string;
recordName: string;
}
type BreadcrumbItemProps = {
scrollRef?: React.RefObject<HTMLDivElement>;
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 (
<Grid gap={ 2 } overflow="hidden" templateColumns="auto 24px" alignItems="center">
<Box
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
{ text }
</Box>
<CopyToClipboard text={ currentUrl } type="link" mx={ 0 } color="text_secondary"/>
</Grid>
);
}
return (
<Grid gap={ 2 } overflow="hidden" templateColumns="auto 24px" alignItems="center">
<LinkInternal
href={ href }
onClick={ onLinkClick }
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{ text }
</LinkInternal>
{ !isLast && <IconSvg name="arrows/east" boxSize={ 6 } color={ iconColor }/> }
</Grid>
);
};
const AddressMudBreadcrumbs = (props: TableViewProps | RecordViewProps) => {
const queryParams = { tab: 'mud', hash: props.hash };
const isMobile = useIsMobile();
const addressQuery = useAddressQuery({ hash: props.hash });
return (
<Box
display={ isMobile ? 'flex' : 'grid' }
flexWrap="wrap"
gridTemplateColumns="20px auto auto auto"
gap={ 2 }
alignItems="center"
className={ props.className }
width="fit-content"
fontSize="sm"
>
<IconSvg name="MUD" boxSize={ 5 } color={ addressQuery.data?.is_verified ? 'green.500' : 'text_secondary' }/>
<BreadcrumbItem
text="MUD World"
href={ route({ pathname: '/address/[hash]', query: queryParams }) }
scrollRef={ props.scrollRef }
/>
<BreadcrumbItem
text={ props.tableName }
href={ route({ pathname: '/address/[hash]', query: { ...queryParams, table_id: props.tableId } }) }
isLast={ !('recordId' in props) }
scrollRef={ props.scrollRef }
/>
{ ('recordId' in props) && (
<BreadcrumbItem
text={ props.recordName }
href={ route({ pathname: '/address/[hash]', query: { ...queryParams, table_id: props.tableId, record_id: props.recordId } }) }
isLast
scrollRef={ props.scrollRef }
/>
) }
</Box>
);
};
export default React.memo(chakra(AddressMudBreadcrumbs));
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(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressMudRecord tableId={ TABLE_ID } recordId={ RECORD_ID }/>
</Box>,
{ 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(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressMudRecord tableId={ TABLE_ID } recordId={ RECORD_ID }/>
</Box>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
});
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<HTMLDivElement>;
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 <ContentLoader/>;
}
if (isError) {
return <Box>error message</Box>;
}
return (
<>
{ data && (
<AddressMudBreadcrumbs
hash={ hash }
tableId={ tableId }
tableName={ data?.table.table_full_name }
recordId={ recordId }
recordName={ data.record.id }
mb={ 6 }
scrollRef={ scrollRef }
/>
) }
<Show above="lg" ssr={ false }>
<Table borderRadius="8px" style={{ tableLayout: 'auto' }} width="100%" overflow="hidden">
{ data?.schema.key_names.length && data?.schema.key_names.map((keyName, index) => (
<Tr key={ keyName } borderBottomStyle={ index === data.schema.key_names.length - 1 ? 'hidden' : 'solid' }>
<Td fontWeight={ 600 } whiteSpace="nowrap" fontSize="sm">
{ keyName } ({ data.schema.key_types[index] })
</Td>
<Td colSpan={ 2 } fontSize="sm">
<Flex justifyContent="space-between">
<TruncatedValue value={ getValueString(data.record.decoded[keyName]) } mr={ 2 }/>
{ index === 0 && <Box color="text_secondary">{ dayjs(data.record.timestamp).format('lll') }</Box> }
</Flex>
</Td>
</Tr>
)) }
<AddressMudRecordValues data={ data }/>
</Table>
</Show>
<Hide above="lg" ssr={ false }>
<>
{ data?.schema.key_names.length && data?.schema.key_names.map((keyName, index) => (
<VStack gap={ 1 } key={ keyName } alignItems="start" fontSize="sm">
<Divider/>
<Text fontWeight={ 600 } whiteSpace="nowrap">
{ keyName } ({ data.schema.key_types[index] })
</Text>
<Text wordBreak="break-word">{ getValueString(data.record.decoded[keyName]) }</Text>
{ index === 0 && <Box color="text_secondary">{ dayjs(data.record.timestamp).format('lll') }</Box> }
</VStack>
)) }
<Table borderRadius="8px" style={{ tableLayout: 'auto' }} width="100%" mt={ 2 } overflow="hidden">
<AddressMudRecordValues data={ data }/>
</Table>
</>
</Hide>
</>
);
};
export default AddressMudRecord;
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 (
<>
<Tr backgroundColor={ valuesBgColor } borderBottomStyle="hidden" >
<Td fontWeight={ 600 } w="100px" fontSize="sm">Field</Td>
<Td fontWeight={ 600 } w="90px" fontSize="sm">Type</Td>
<Td fontWeight={ 600 } fontSize="sm">Value</Td>
</Tr>
{
data?.schema.value_names.map((valName, index) => (
<Tr key={ valName } backgroundColor={ valuesBgColor } borderBottomStyle="hidden">
<Td fontSize="sm" w="100px" py={ 0 } pb={ 4 } pr={ 0 }wordBreak="break-all">{ valName }</Td>
<Td fontSize="sm" w="90px" py={ 0 } pb={ 4 } wordBreak="break-all">{ data.schema.value_types[index] }</Td>
<Td fontSize="sm" wordBreak="break-word" py={ 0 } pb={ 4 }>
<Box>
{ getValueString(data.record.decoded[valName]) }
</Box>
</Td>
</Tr>
))
}
</>
);
};
export default AddressMudRecordValues;
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<string>(value);
const onFilter = React.useCallback(() => {
handleFilterChange(filterValue);
}, [ handleFilterChange, filterValue ]);
return (
<TableColumnFilter
columnName={ columnName }
title={ title }
isActive={ Boolean(value) }
isFilled={ filterValue !== value }
onFilter={ onFilter }
isLoading={ isLoading }
w="350px"
>
<FilterInput
initialValue={ value }
size="xs"
onChange={ setFilterValue }
placeholder={ columnName }
/>
</TableColumnFilter>
);
};
export default AddressMudRecordsKeyFilter;
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<React.SetStateAction<AddressMudRecordsFilter>>;
filters: AddressMudRecordsFilter;
toggleTableHasHorisontalScroll: () => void;
scrollRef?: React.RefObject<HTMLDivElement>;
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<number>(isMobile ? 2 : 0);
const [ isOpened, setIsOpened ] = useBoolean(false);
const [ hasCut, setHasCut ] = useBoolean(isMobile ? totalColsCut > MIN_CUT_COUNT : true);
const containerRef = React.useRef<HTMLTableElement>(null);
const tableRef = React.useRef<HTMLTableElement>(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 <Box w="100%" ref={ containerRef }></Box>;
}
const cutButton = (
<Th width={ `${ CUT_COL_WIDTH }px ` } verticalAlign="baseline">
<Tooltip label={ isOpened ? 'Hide columns' : 'Show all columns' }>
<Link onClick={ toggleIsOpen } aria-label="show/hide columns">...</Link>
</Tooltip>
</Th>
);
return (
// can't implement both horisontal table scroll and sticky header
<Box maxW="100%" overflowX={ hasHorizontalScroll ? 'scroll' : 'unset' } whiteSpace="nowrap" ref={ tableRef }>
<Table variant="simple" size="sm" style={{ tableLayout: 'fixed' }}>
<Thead top={ hasHorizontalScroll ? 0 : top } display={ hasHorizontalScroll ? 'table' : 'table-header-group' } w="100%">
<Tr >
{ keys.map((keyName, index) => {
const text = getNameTypeText(keyName, data.schema.key_types[index]);
return (
<Th key={ keyName } { ...tdStyles }>
{ index < 2 ? (
<Flex alignItems="center">
<Link
onClick={ onKeySortClick }
data-id={ index }
display="flex"
alignItems="start"
lineHeight="20px"
mr={ 2 }
>
{ sorting?.sort === `key${ index }` && sorting.order && (
<Box minW="24px" w="24px" mr={ 2 }>
<IconSvg
name="arrows/east"
boxSize={ 5 }
transform={ sorting.order === 'asc' ? 'rotate(-90deg)' : 'rotate(90deg)' }
/>
</Box>
) }
{ text }
</Link>
<Box minW="20px" w="20px">
<AddressMudRecordsKeyFilter
value={ filters[index === 0 ? 'filter_key0' : 'filter_key1'] }
title={ text }
columnName={ keyName }
handleFilterChange={ handleFilterChange(index === 0 ? 'filter_key0' : 'filter_key1') }
/>
</Box>
</Flex>
) : text }
</Th>
);
}) }
{ values.map((valName, index) => (
<Th key={ valName } { ...tdStyles }>
{ capitalizeFirstLetter(valName) } ({ data.schema.value_types[index] })
</Th>
)) }
{ hasCut && !isOpened && cutButton }
<Th { ...tdStyles }>Modified</Th>
{ hasCut && isOpened && cutButton }
</Tr>
</Thead>
<Tbody display={ hasHorizontalScroll ? 'table' : 'table-row-group' } w="100%">
{ data.items.map((item) => (
<Tr key={ item.id }>
{ keys.map((keyName, index) => (
<Td key={ keyName } backgroundColor={ keyBgColor } { ...tdStyles }>
{ index === 0 ? (
<LinkInternal
onClick={ onRecordClick }
data-id={ item.id }
fontWeight={ 700 }
href={ route({ pathname: '/address/[hash]', query: { hash, tab: 'mud', table_id: data.table.table_id, record_id: item.id } }) }
>
{ getValueString(item.decoded[keyName]) }
</LinkInternal>
) : getValueString(item.decoded[keyName]) }
</Td>
)) }
{ values.map((valName) =>
<Td key={ valName } { ...tdStyles }>{ getValueString(item.decoded[valName]) }</Td>) }
{ hasCut && !isOpened && <Td width={ `${ CUT_COL_WIDTH }px ` }></Td> }
<Td { ...tdStyles } color="text_secondary">{ dayjs(item.timestamp).format('lll') }</Td>
{ hasCut && isOpened && <Td width={ `${ CUT_COL_WIDTH }px ` }></Td> }
</Tr>
)) }
</Tbody>
</Table>
</Box>
);
};
export default AddressMudRecordsTable;
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(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressMudTable tableId={ TABLE_ID }/>
</Box>,
{ 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(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressMudTable tableId={ TABLE_ID }/>
</Box>,
{ 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(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressMudTable tableId={ TABLE_ID }/>
</Box>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
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<HTMLDivElement>;
isQueryEnabled?: boolean;
tableId: string;
}
type FilterKeys = keyof AddressMudRecordsFilter;
const AddressMudTable = ({ scrollRef, tableId, isQueryEnabled = true }: Props) => {
const router = useRouter();
const [ sorting, setSorting ] =
React.useState<AddressMudRecordsSorting | undefined>(getSortParamsFromQuery<AddressMudRecordsSorting>(router.query, SORT_SEQUENCE));
const [ filters, setFilters ] = React.useState<AddressMudRecordsFilter>({});
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 <ContentLoader/>;
}
const filtersTags = hasActiveFilters ? (
<HStack gap={ 3 } mb={ 1 }>
{ Object.entries(filters).map(([ key, value ]) => {
const index = key as FilterKeys === 'filter_key0' ? 0 : 1;
return (
<Tag display="inline-flex" key={ key } maxW="360px" colorScheme="blue">
<chakra.span color="text_secondary" >{
getNameTypeText(data?.schema.key_names[index] || '', data?.schema.key_types[index] || '') }
</chakra.span>
<chakra.span color="text" overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
{ nbsp }
{ value }
</chakra.span>
<TagCloseButton onClick={ onRemoveFilterClick(key as FilterKeys) }/>
</Tag>
);
}) }
</HStack>
) : null;
const breadcrumbs = data ? (
<AddressMudBreadcrumbs
hash={ hash }
tableId={ tableId }
tableName={ data?.table.table_full_name }
scrollRef={ scrollRef }
mb={ hasActiveFilters ? 4 : 0 }
/>
) : null;
const actionBar = (!isMobile || hasActiveFilters || pagination.isVisible) && (
<ActionBar mt={ -6 } showShadow={ tableHasHorisontalScroll } justifyContent="space-between" alignItems={ hasActiveFilters ? 'start' : 'center' }>
<Box>
{ !isMobile && breadcrumbs }
{ filtersTags }
</Box>
<Pagination ml={{ base: 0, lg: 8 }} { ...pagination }/>
</ActionBar>
);
const content = data?.items ? (
<AddressMudRecordsTable
data={ data }
top={ actionBatHeight }
sorting={ sorting }
toggleSorting={ toggleSorting }
setFilters={ setFilters }
filters={ filters }
toggleTableHasHorisontalScroll={ setTableHasHorisontalScroll.toggle }
scrollRef={ scrollRef }
hash={ hash }
/>
) : null;
const emptyText = (
<>
<chakra.span>There are no records for </chakra.span>
{ data?.table.table_full_name ? <chakra.span fontWeight={ 600 }>{ data?.table.table_full_name }</chakra.span> : 'this table' }
</>
);
return (
<>
{ isMobile && (
<Box mb={ 6 }>{ breadcrumbs }</Box>
) }
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText={ emptyText }
filterProps={{
emptyFilteredText: `Couldn${ apos }t find records that match your filter query.`,
hasActiveFilters: Object.values(filters).some(Boolean),
}}
content={ content }
actionBar={ actionBar }
showActionBarIfEmpty={ !isMobile }
mt={ data?.items.length ? 0 : 2 }
/>
</>
);
};
export default AddressMudTable;
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(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressMudTables/>
</Box>,
{ 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(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressMudTables/>
</Box>,
{ hooksConfig },
);
await component.locator('div[aria-label="View schema"]').first().click();
await expect(component).toHaveScreenshot();
});
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<HTMLDivElement>;
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<string>(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 = (
<FilterInput
w={{ base: '100%', lg: '360px' }}
minW={{ base: 'auto', lg: '250px' }}
size="xs"
onChange={ setSearchTerm }
placeholder="Search by name, namespace or table ID..."
initialValue={ searchTerm }
isLoading={ isInitialLoading }
/>
);
const actionBar = (
<ActionBar mt={ -6 } showShadow justifyContent="space-between">
{ searchInput }
<Pagination ml={{ base: 0, lg: 8 }} { ...pagination }/>
</ActionBar>
);
const content = data?.items ? (
<>
<Hide below="lg" ssr={ false }>
<AddressMudTablesTable
items={ data.items }
isLoading={ isPlaceholderData }
top={ ACTION_BAR_HEIGHT_DESKTOP }
scrollRef={ scrollRef }
hash={ hash }
/>
</Hide>
<Show below="lg" ssr={ false }>
{ data.items.map((item, index) => (
<AddressMudTablesListItem
key={ item.table.table_id + (isPlaceholderData ? String(index) : '') }
item={ item }
isLoading={ isPlaceholderData }
hash={ hash }
/>
)) }
</Show>
</>
) : null;
return (
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no tables for this address."
filterProps={{
emptyFilteredText: `Couldn${ apos }t find tables that match your filter query.`,
hasActiveFilters: Boolean(searchTerm),
}}
content={ content }
actionBar={ actionBar }
/>
);
};
export default AddressMudTables;
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<HTMLDivElement>;
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 (
<ListItemMobile rowGap={ 3 } fontSize="sm" py={ 3 }>
<Flex w="100%">
<Skeleton isLoaded={ !isLoading }>
<Link display="block">
<IconSvg
name="arrows/east-mini"
transform={ isOpened ? 'rotate(270deg)' : 'rotate(180deg)' }
boxSize={ 6 }
cursor="pointer"
onClick={ setIsOpened.toggle }
transitionDuration="faster"
aria-label="View schema"
/>
</Link>
</Skeleton>
<Box flexGrow="1">
<Flex justifyContent="space-between" height={ 6 } alignItems="center" mb={ 3 }>
<Skeleton isLoaded={ !isLoading }>
<LinkInternal
onClick={ onTableClick }
data-id={ item.table.table_id }
fontWeight={ 500 }
href={ route({ pathname: '/address/[hash]', query: { hash, tab: 'mud', table_id: item.table.table_id } }) }
>
{ item.table.table_full_name }
</LinkInternal>
</Skeleton>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
{ item.table.table_type }
</Skeleton>
</Flex>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<HashStringShorten hash={ item.table.table_id } type="long"/>
</Skeleton>
</Box>
</Flex>
{ isOpened && (
<Grid templateColumns="48px 1fr" gap="8px 24px" fontWeight={ 500 } w="100%">
{ Boolean(item.schema.key_names.length) && (
<>
<Text lineHeight="24px">Key</Text>
<VStack gap={ 1 } alignItems="start">
{ item.schema.key_names.map((name, index) => (
<Tag key={ name }>
<chakra.span fontWeight={ 700 }>{ item.schema.key_types[index] }</chakra.span> { name }
</Tag>
)) }
</VStack>
</>
) }
<GridItem colSpan={ 2 }><Divider/></GridItem>
<Text lineHeight="24px">Value</Text>
<VStack gap={ 1 } alignItems="start">
{ item.schema.value_names.map((name, index) => (
<Text key={ name }>
<chakra.span fontWeight={ 700 }>{ item.schema.value_types[index] }</chakra.span> { name }
</Text>
)) }
</VStack>
</Grid>
) }
</ListItemMobile>
);
};
export default React.memo(AddressMudTablesListItem);
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<HTMLDivElement>;
hash: string;
}
//sorry for the naming
const AddressMudTablesTable = ({ items, isLoading, top, scrollRef, hash }: Props) => {
return (
<Table variant="simple" size="sm" style={{ tableLayout: 'auto' }}>
<Thead top={ top }>
<Tr>
<Th width="24px"></Th>
<Th>Full name</Th>
<Th>Table ID</Th>
<Th>Type</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<AddressMudTablesTableItem
key={ item.table.table_id + (isLoading ? String(index) : '') }
item={ item }
isLoading={ isLoading }
scrollRef={ scrollRef }
hash={ hash }
/>
)) }
</Tbody>
</Table>
);
};
export default AddressMudTablesTable;
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<HTMLDivElement>;
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 (
<>
<Tr borderStyle={ isOpened ? 'hidden' : 'unset' }>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading }>
<Link display="block">
<IconSvg
name="arrows/east-mini"
transform={ isOpened ? 'rotate(270deg)' : 'rotate(180deg)' }
boxSize={ 6 }
cursor="pointer"
onClick={ setIsOpened.toggle }
transitionDuration="faster"
aria-label="View schema"
/>
</Link>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading }>
<LinkInternal
href={ route({ pathname: '/address/[hash]', query: { hash, tab: 'mud', table_id: item.table.table_id } }) }
data-id={ item.table.table_id }
onClick={ onTableClick }
fontWeight={ 700 }
>
{ item.table.table_full_name }
</LinkInternal>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading }>
{ item.table.table_id }
</Skeleton>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading }>
{ item.table.table_type }
</Skeleton>
</Td>
</Tr>
{ isOpened && (
<Tr>
<Td pt={ 0 }></Td>
<Td colSpan={ 3 } pt={ 0 }>
<Table>
{ Boolean(item.schema.key_names.length) && (
<Tr>
<Td width="80px" fontSize="sm" fontWeight={ 600 } py={ 2 } pl={ 0 } verticalAlign="middle">Key</Td>
<Td py={ 2 }>
<VStack gap={ 1 } alignItems="start">
{ item.schema.key_names.map((name, index) => (
<Tag key={ name }>
<chakra.span fontWeight={ 700 }>{ item.schema.key_types[index] }</chakra.span> { name }
</Tag>
)) }
</VStack>
</Td>
</Tr>
) }
<Tr borderBottomStyle="hidden">
<Td width="80px" fontSize="sm" fontWeight={ 600 } py={ 2 } pl={ 0 } >Value</Td>
<Td fontSize="sm" py={ 2 }>
<VStack gap={ 1 } alignItems="start">
{ item.schema.value_names.map((name, index) => (
<Text key={ name }>
<chakra.span fontWeight={ 700 }>{ item.schema.value_types[index] }</chakra.span> { name }
</Text>
)) }
</VStack>
</Td>
</Tr>
</Table>
</Td>
</Tr>
) }
</>
);
};
export default React.memo(AddressMudTablesTableItem);
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<string>) => {
if (Array.isArray(value)) {
return value.join(', ');
}
return value.toString();
};
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 (
<ListItemMobile rowGap={ 3 }>
<AddressEntity
address={ item.address }
isLoading={ isLoading }
fontWeight={ 700 }
mr={ 2 }
truncation="constant_long"
/>
<HStack spacing={ 3 } maxW="100%" alignItems="flex-start">
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 } flexShrink={ 0 }>{ `Balance ${ currencyUnits.ether }` }</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" minW="0" whiteSpace="pre-wrap">
<span>{ addressBalance.dp(8).toFormat() }</span>
</Skeleton>
</HStack>
<HStack spacing={ 3 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Txn count</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary">
<span>{ Number(item.tx_count).toLocaleString() }</span>
</Skeleton>
</HStack>
</ListItemMobile>
);
};
export default React.memo(MudWorldsListItem);
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<MudWorldItem>;
top: number;
isLoading?: boolean;
}
const MudWorldsTable = ({ items, top, isLoading }: Props) => {
return (
<Table variant="simple" size="sm" style={{ tableLayout: 'auto' }}>
<Thead top={ top }>
<Tr>
<Th>Address</Th>
<Th isNumeric>{ `Balance ${ currencyUnits.ether }` }</Th>
<Th isNumeric>Txn count</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<MudWorldsTableItem
key={ String(item.address.hash) + (isLoading ? index : '') }
item={ item }
isLoading={ isLoading }
/>
)) }
</Tbody>
</Table>
);
};
export default MudWorldsTable;
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 (
<Tr>
<Td verticalAlign="middle">
<AddressEntity address={ item.address } isLoading={ isLoading } fontWeight={ 700 }/>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading } display="inline-block" maxW="100%">
<Text lineHeight="24px" as="span">{ addressBalanceChunks[0] }</Text>
{ addressBalanceChunks[1] && <Text lineHeight="24px" as="span">.</Text> }
<Text lineHeight="24px" variant="secondary" as="span">{ addressBalanceChunks[1] }</Text>
</Skeleton>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading } display="inline-block" lineHeight="24px">
{ Number(item.tx_count).toLocaleString() }
</Skeleton>
</Td>
</Tr>
);
};
export default MudWorldsTableItem;
......@@ -42,7 +42,7 @@ const NameDomainsListItem = ({
<ListItemMobileGrid.Label isLoading={ isLoading }>Registered on</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
<div>{ dayjs(registrationDate).format('MMM DD YYYY HH:mm:ss A') }</div>
<div>{ dayjs(registrationDate).format('lll') }</div>
<div> { dayjs(registrationDate).fromNow() }</div>
</Skeleton>
</ListItemMobileGrid.Value>
......@@ -54,7 +54,7 @@ const NameDomainsListItem = ({
<ListItemMobileGrid.Label isLoading={ isLoading }>Expiration date</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">
<div>{ dayjs(expiryDate).format('MMM DD YYYY HH:mm:ss A') } </div>
<div>{ dayjs(expiryDate).format('lll') } </div>
<NameDomainExpiryStatus date={ expiryDate }/>
</Skeleton>
</ListItemMobileGrid.Value>
......
......@@ -32,7 +32,7 @@ const NameDomainsTableItem = ({
<Td verticalAlign="middle" pl={ 9 }>
{ registrationDate && (
<Skeleton isLoaded={ !isLoading }>
{ dayjs(registrationDate).format('MMM DD YYYY HH:mm:ss A') }
{ dayjs(registrationDate).format('lll') }
<chakra.span color="text_secondary"> { dayjs(registrationDate).fromNow() }</chakra.span>
</Skeleton>
) }
......@@ -40,7 +40,7 @@ const NameDomainsTableItem = ({
<Td verticalAlign="middle">
{ expiryDate && (
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">
<span>{ dayjs(expiryDate).format('MMM DD YYYY HH:mm:ss A') } </span>
<span>{ dayjs(expiryDate).format('lll') } </span>
<NameDomainExpiryStatus date={ expiryDate }/>
</Skeleton>
) }
......
......@@ -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<RoutedTab> = React.useMemo(() => {
return [
config.features.mudFramework.isEnabled && mudTablesCountQuery.data && mudTablesCountQuery.data > 0 && {
id: 'mud',
title: 'MUD',
count: mudTablesCountQuery.data,
component: <AddressMud scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>,
},
{
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<EntityTag> = 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 = (
<EntityTags
......@@ -246,16 +275,21 @@ const AddressPageContent = () => {
<RoutedTabs tabs={ tabs } tabListProps={{ mt: 6 }} isLoading={ isTabsLoading }/>;
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 = (
......
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(<MudWorlds/>);
await expect(component).toHaveScreenshot();
});
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 ? (
<>
<Show below="lg" ssr={ false }>
{ data.items.map(((item, index) => (
<MudWorldsListItem
key={ item.address.hash + (isPlaceholderData ? String(index) : '') }
item={ item }
isLoading={ isPlaceholderData }
/>
))) }
</Show>
<Hide below="lg" ssr={ false }>
<MudWorldsTable items={ data.items } top={ pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 } isLoading={ isPlaceholderData }/>
</Hide>
</>
) : null;
const actionBar = pagination.isVisible ? (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) : null;
return (
<>
<PageTitle title="MUD worlds" withTextAd/>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no MUD worlds."
content={ content }
actionBar={ actionBar }
/>
</>
);
};
export default MudWorlds;
......@@ -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 (
<Tooltip label={ copied ? 'Copied' : 'Copy to clipboard' } isOpen={ isOpen || copied }>
<Tooltip label={ copied ? 'Copied' : `Copy${ type === 'link' ? ' link ' : ' ' }to clipboard` } isOpen={ isOpen || copied }>
<IconButton
aria-label="copy"
icon={ <IconSvg name="copy" boxSize={ size }/> }
icon={ <IconSvg name={ type === 'link' ? 'link' : 'copy' } boxSize={ size }/> }
boxSize={ size }
color={ iconColor }
variant="simple"
......
......@@ -13,8 +13,9 @@ type FilterProps = {
type Props = {
isError: boolean;
items?: Array<unknown>;
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 ? <Text className={ props.className }>{ props.emptyText }</Text> : null;
return (
<>
{ props.showActionBarIfEmpty && props.actionBar }
{ props.emptyText && <Text className={ props.className }>{ props.emptyText }</Text> }
</>
);
}
return (
......
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 (
<>
<Flex alignItems="center" justifyContent="space-between">
<Text color="text_secondary" fontWeight="600">{ title }</Text>
{ hasReset && (
<Link
onClick={ onReset }
cursor={ isFilled ? 'pointer' : 'unset' }
opacity={ isFilled ? 1 : 0.2 }
_hover={{
color: isFilled ? 'link_hovered' : 'none',
}}
>
Reset
</Link>
) }
</Flex>
{ children }
<Button
isDisabled={ !isFilled }
onClick={ onFilterClick }
w="fit-content"
>
Filter
</Button>
</>
);
};
const TableColumnFilter = ({ columnName, isActive, className, isLoading, ...props }: Props) => {
return (
<TableColumnFilterWrapper
isActive={ isActive }
columnName={ columnName }
className={ className }
isLoading={ isLoading }
>
<TableColumnFilterContent { ...props }/>
</TableColumnFilterWrapper>
);
};
export default chakra(TableColumnFilter);
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<React.ReactNode>;
};
const modifiedChildren = React.cloneElement(
child,
{ onClose },
);
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy lazyBehavior="unmount" strategy="fixed">
<PopoverTrigger>
<IconButton
onClick={ onToggle }
aria-label={ `filter by ${ columnName }` }
variant="ghost"
w="20px"
h="20px"
icon={ <IconSvg name="filter" w="19px" h="19px"/> }
isActive={ isActive }
isDisabled={ isLoading }
borderRadius="4px"
color="text_secondary"
/>
</PopoverTrigger>
<PopoverContent className={ className }>
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 3 }>
{ modifiedChildren }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default chakra(TableColumnFilterWrapper);
......@@ -8,7 +8,7 @@ interface Props {
const Container = ({ children, className }: Props) => {
return (
<Box className={ className } minWidth={{ base: '100vw', lg: 'fit-content' }}>
<Box className={ className } minWidth={{ base: '100vw', lg: 'auto' }}>
{ children }
</Box>
);
......
......@@ -3,8 +3,20 @@ export default function getNextSortValue<SortField extends string, Sort extends
) {
return (prevValue: Sort | undefined) => {
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<T>(sequence: Array<T>, 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<Order> = [ 'desc', 'asc', undefined ];
export const getNextOrderValue = (getNextValueFromSequence<Order>).bind(undefined, sequence);
import type { Query } from 'nextjs-routes';
import getQueryParamString from 'lib/router/getQueryParamString';
export default function getSortParamsFromQuery<T>(query: Query, sortOptions: Record<string, Array<string | undefined>>) {
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);
}
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':
......
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;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment