Clusters name sevices integration (#2994)

* adds clusters api configs

* adds clusters api configs

* adds api types

* adds props routes and titles for cluster pages

* adds query param enum for clusters api

* adds clusters svg

* adds dynamic page routes

* adds clusters entity ui in ui/shared/entities

* adds functions to detect input type; will be used to check whether a user is searching for a cluster name vs a evm address

* adds stubs for loading skeleton view

* adds pagination hook

* adds query param hooks for clusters trpc endpoints

* adds query param hooks for clusters trpc endpoints

* adds components that make up the /clusters ui

* adds components for /cluster ui

* adds clusters api endpoint to query by cluster id

* implements call to cluster id endpoint to get ac
tive chain count

* adds clusters search types and in api category

* creates clusters search hook and wraps useQuickSearchQuery hook

* replaces useQuickSearchQuery for useSearchWithClusters hook for both functionality

* add search suggestion ui

* adds optional chaining to handle react re-render when search type changes

* adds cluster ui to ui/pages/address on address details

* prevents clicking on cluster suggestion if address is non EVM

* adds util functions for testability along with tests, for action bar component logic

* refactor - extract clusters display logic wit
h tests

* refactor: extract page utilities from Clusters.tsx with tests

* adds jest testing infra for react components

* adds hook testing coverage

* adds component and integration tests

* adds detectInputType util tests

* removes generic parameters from clusters pagination types

* replaces any type with proper cluster config types

* adds cluster routes to metadata and analytics mappings

* updates order and stacks action bar items vertically when mobile view

* repositions clusters logo

* refactors search logic to render community clusters in search sugge
st

* adding clusters env vars

- add NEXT_PUBLIC_CLUSTERS_API_HOST and NEXT_PUBLIC_CLUSTERS_CDN_URL env vars
- configure clusters api integration in configs/app/apis.ts and features/clusters.ts
- adds clusters env vars to test env (.env.jest and .env.localhost)
- update CI deploy configs for both review envs
- add env vars validation schema
- document new vars in ENVS.md with usage examples

* centers directory/leaderboard toggle in mobile view

* resizes and sets proper color to logo in entity component

* hides clusters nav when clusters env is missing

* removes duplicate trailing slashes in cluster community names

* Bump `react` to v19 (#2782)

Co-authored-by: tom <[email protected]>

* change links to ton explorer

* fix internal links in ENVS.md file

* tx info: tac operation stage could be an array

* fix display of ad banner

* tac ui updates

* add more "button click" events

* update clock icon

* update screenshots

* fix age filter reset

* change tac operation status once again

* fix address icon tooltip content

* [skip ci] label released issues workflow: don't override release tag if it exists

* puzzle-15 (#2471)

* puzzle-15

* puzzle-review

* puzzle game

* puzzle fixes

* separate puzzle feature in readme

* fix testnet and rollup stage badges

* New styles for pagination and "Add / Remove" buttons (#2791)

* update styles for add / remove buttons

* update pagination styles

* change border color for filled inputs

* change row gap for alerts

* update icons and screenshots

* fix tests

* update buttons for contract method form

* update screenshot

* Gas tracker: bug fixes and improvements (#2792)

* Gas tracker page: hide `Gas price history` if chart is not enabled

Fixes #2781

* change default value for NEXT_PUBLIC_GAS_TRACKER_UNITS for testnets

* fix env for pw tests

* Public tags form improvements (#2796)

* adjust validation of comment field

* add icon url to public tag form

* update screenshots

* fix ts

* NFT marketplaces: make `collection_url` and `instance_url ` optional (#2797)

Fixes #2614

* Bump brace-expansion from 1.1.11 to 1.1.12 in /deploy/tools/affected-tests (#2801)

Bump brace-expansion in /deploy/tools/affected-tests

Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: Hanzo Dev <[email protected]>

* allow toaster to render custom description

* Encode the address hash for links to the TON chain (#2813)

Encode the address hash for links to the TON chain.

* Support redirects for known address discrepancies (#2799)

* Support redirects for known address discrepancies

Fixes #2793

* fix redirect to internal txs in the block

* add address user ops redirect

* Celo: support L2 epochs (#2784)

* epochs page

* epoch rewards distribution

* epoch election rewards

* address epoch rewards

* block epoch tag

* add some  tests

* adjust epoch details for L1 epochs

* support non-finalized epochs

* fixes after design review

* add truncation to block numbers on mobile

* update epochs table layout

* update timestamp hint

* fix bug with subheading of first epoch

* fixes of mobile view

* update screenshots

* Icons in footer links (#2809)

* Icons in footer links

Fixes #2539

* fix envs validator

* Metadata: send address to fetchers once visited (#2814)

* Metadata: send address to fetchers once visited

Fixes #2723

* fix ts

* replaces jest-dom matchers with standard matchers

* makes /clusters page default view the directory instead of leaderboard, including jest coverage

* updates columnn name from backing to total backing, in leaderboard display

* alphabetically reorders clusters export

* adds Cluster section to the Table of Cont
ents at top of page

* removes redundant Flex component wrapper around ClustersEntity

* uses standard toolkit values for border radius and color

* uses getFeaturePayload util instead of type coercion

* extends ImageProps interface to ClusterIconProps, and uses getFeaturePayload util instead of type coercion

* adds evm address regexp in regexp.ts as single source of truth

* updates imports for files that used form validator address regexp to use single source of truth

* removes unused args, unnecessary type coercions and memoizations. upper snake case constant values.

* sets correct action bar height using ACTION_BAR_HEIGHT_DESKTOP constant

* implements TimeFormatToggle to Joined column

* simplifies conditionals and removes unnecessary Box component wrapper

* displays standard 404 error screen on  erroneous ClusterDetails query

* standardizes details page to display ETH value and time in line with rest of app

* removes redundant debouncing

* moves useQueryParams hook to lib/router and updates imports and test to account

* moves config.features.cluster
s.isEnabled to top of the function and utilizes select function of useQuery in useAp
iQuery

* removes unnecessary 3rd column and isContractVerified since it is always false

* checks clusters enable for placeholder

* makes isClusterClickable condition more generic for future repurpose

* splits isEvmAddress out to general purpose utility in lib/address, update imports and test

* moves all cluster hooks and tests to lib/clusters folder

* wraps action bar with standard button wrapper

* makes nextjs-routes mock condition stricter

* adds mock clusters api data

* adds pw tests for cluster detail page

* adds pw tests for clusters directory page

* adds pw tests for clustersentity ui component

* adds mock envs for pw tests

* adds clusters api host and cdn url to env.pw

* fixes resolve react-hooks/rules-of-hooks violation in cluster search while keeping search optimization

* throws error instead of using app error component

* uses semantic token to reference color as per standard method in chakra ui

* removes empty or loading state pw tests

* pw tests - uses mockAssetResponse for icons, removes redundant tests, and updates vie
wport to narrow test case

* migrates all clusters pw tests from page.route to mockApiResponse

* updates docker generated pw screenshots

* removes object cloning in useSearchWithClusters hook

* uses standardized chakra name for backgroundColor

* resolves Clusters.pw.tsx docker screen generation issues and updates screenshots

* removes unneeded screenshots for Cluster.pw.tsx

* standardizes mobile testing approach with +@mobile tag by removing explicit viewport config, updates screenshots

* reverts expanded pw test coverage for ClustersEntity

* removes all clusters pw screenshots

* removes nested describe which was resulting in a failed test

* uploads latest docker generated screenshots for pw

* force trigger for conflict check

* removes repeating var from blockchainNavItems array

* fix screenshot merge conflicts - use upstream versions

* updates yarn.lock with upstream lib

* fix jest tests by mocking useQuery directly to resolve QueryClient context issues

* creates global mock object and proper typing to reduce duplication

* updates clusters pw screenshots with docker generation. update helia to 3.2.0 and pinned esbuild to 0.21.5. update docker dep script.

* updates yarn.lock

* removes unused imports from upstream/main

* add vite aliases for @libp2p/utils and @helia/verified-fetch to resolve playwright build failures

* updates pw screenshots for hero banner, home page and desktop header

* downgrades helia to v2.6.12

* revert changes in yarn.lock file related to helia package

* updates yarn.lock with helia downgrade

* revert changes in yarn.lock

* move clusters to the tab on the name service page

* fix tests

* small tweaks

* update screenshots

---------

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: Richard Oh <[email protected]>
Co-authored-by: Max Alekseenko <[email protected]>
Co-authored-by: isstuev <[email protected]>
Co-authored-by: Igor Stuev <[email protected]>
Co-authored-by: Hanzo Dev <[email protected]>
Co-authored-by: Richard J Oh <[email protected]>
This commit is contained in:
tom goriunov
2025-09-08 19:21:15 +02:00
committed by GitHub
co-authored by Richard Oh Max Alekseenko isstuev Igor Stuev Hanzo Dev Richard J Oh
parent ea91a09ae0
commit 8f854ab8ad
129 changed files with 5040 additions and 302 deletions
+12
View File
@@ -167,6 +167,17 @@ const visualizeApi = (() => {
});
})();
const clustersApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_CLUSTERS_API_HOST');
if (!apiHost) {
return;
}
return Object.freeze({
endpoint: apiHost,
});
})();
const zetachainApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST');
if (!apiHost) {
@@ -193,6 +204,7 @@ const apis: Apis = Object.freeze({
general: generalApi,
admin: adminApi,
bens: bensApi,
clusters: clustersApi,
contractInfo: contractInfoApi,
metadata: metadataApi,
multichain: multichainApi,
+26
View File
@@ -0,0 +1,26 @@
import type { Feature } from './types';
import apis from '../apis';
import { getEnvValue } from '../utils';
const title = 'Clusters universal name service';
const config: Feature<{ cdnUrl: string }> = (() => {
const cdnUrl = getEnvValue('NEXT_PUBLIC_CLUSTERS_CDN_URL') || 'https://cdn.clusters.xyz';
if (apis.clusters) {
return Object.freeze({
title,
isEnabled: true,
cdnUrl,
});
}
return Object.freeze({
title,
isEnabled: false,
cdnUrl,
});
})();
export default config;
+1
View File
@@ -10,6 +10,7 @@ export { default as beaconChain } from './beaconChain';
export { default as bridgedTokens } from './bridgedTokens';
export { default as blockchainInteraction } from './blockchainInteraction';
export { default as celo } from './celo';
export { default as clusters } from './clusters';
export { default as csvExport } from './csvExport';
export { default as dataAvailability } from './dataAvailability';
export { default as deFiDropdown } from './deFiDropdown';
+4
View File
@@ -9,6 +9,10 @@ NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Clusters feature
NEXT_PUBLIC_CLUSTERS_API_HOST=https://api.clusters.xyz
NEXT_PUBLIC_CLUSTERS_CDN_URL=https://cdn.clusters.xyz
# Instance ENVs
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'trustblock', 'smartmuv', 'blockscoutbadges', 'etherscore', 'bubblemaps', 'goplus', 'gitpoap', 'kwik', 'drops', 'humanode']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
+4
View File
@@ -46,3 +46,7 @@ NEXT_PUBLIC_STATS_API_HOST=https://localhost:3004
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://localhost:3006
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
# clusters feature
NEXT_PUBLIC_CLUSTERS_API_HOST=https://api.clusters.xyz
NEXT_PUBLIC_CLUSTERS_CDN_URL=https://cdn.clusters.xyz
+2
View File
@@ -59,4 +59,6 @@ NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=tom
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false
NEXT_PUBLIC_GAS_TRACKER_UNITS=['usd','gwei']
NEXT_PUBLIC_CLUSTERS_API_HOST=https://api.clusters.xyz
NEXT_PUBLIC_CLUSTERS_CDN_URL=https://cdn.clusters.xyz
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE=jazzicon
+3 -1
View File
@@ -357,7 +357,7 @@ const rollupSchema = yup
}),
});
const celoSchema = yup
const celoSchema = yup
.object()
.shape({
NEXT_PUBLIC_CELO_ENABLED: yup.boolean(),
@@ -1021,6 +1021,8 @@ const schema = yup
NEXT_PUBLIC_VISUALIZE_API_BASE_PATH: yup.string(),
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_NAME_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_CLUSTERS_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_CLUSTERS_CDN_URL: yup.string().test(urlTest),
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_WEB3_WALLETS: yup
.mixed()
+2
View File
@@ -78,6 +78,8 @@ frontend:
PROMETHEUS_METRICS_ENABLED: true
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: true
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED: true
NEXT_PUBLIC_CLUSTERS_API_HOST: https://api.clusters.xyz
NEXT_PUBLIC_CLUSTERS_CDN_URL: https://cdn.clusters.xyz
envFromSecret:
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
+12
View File
@@ -56,6 +56,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d
- [Transaction interpretation](#transaction-interpretation)
- [Verified tokens info](#verified-tokens-info)
- [Name service integration](#name-service-integration)
- [Clusters universal name service](#clusters-universal-name-service)
- [Metadata service integration](#metadata-service-integration)
- [Public tag submission](#public-tag-submission)
- [Data availability](#data-availability)
@@ -665,6 +666,17 @@ This feature allows resolving blockchain addresses using human-readable domain n
&nbsp;
### Clusters universal name service
This feature integrates [Clusters.xyz](https://clusters.xyz/) universal naming service, enabling users to look up and track cross-chain identities through human-readable names like "vitalik/" or "uniswap/". Unlike traditional domain services that work on single chains, clusters span multiple blockchains - one cluster name can represent addresses on Ethereum, Base, Optimism, and other networks. This integration adds cluster lookup pages (/clusters/[name]), a clusters directory (/clusters), search functionality in the main search bar, and displays cluster profile information and images throughout the explorer.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_CLUSTERS_API_HOST | `string` | Clusters.xyz API endpoint for fetching cluster data, directory listings, and cross-chain address mappings | Required | - | `https://example.com/clusters-api` | <upcoming> |
| NEXT_PUBLIC_CLUSTERS_CDN_URL | `string` | CDN base URL for serving cluster profile images and avatars displayed in search results and cluster pages | - | `https://cdn.clusters.xyz` | `https://your-cdn.example.com` | <upcoming> |
&nbsp;
### Metadata service integration
This feature allows name tags and other public tags for addresses.
+14
View File
@@ -0,0 +1,14 @@
<svg id="CIRCLE_x5F_BOTTOM_x5F_RIGHT_copy_2" xmlns="http://www.w3.org/2000/svg" viewBox="480 180 950 800">
<defs>
<style>
.cls-2{opacity:.84}.cls-3{opacity:.58}.cls-4{opacity:.4}
</style>
</defs>
<path d="M1367.46 574.95c-47.99 47.66-114.1 77.11-187.08 77.11s-139.09-29.45-187.08-77.11c-.44.43-.87.87-1.3 1.3 47.66 47.99 77.11 114.1 77.11 187.08S1039.66 902.42 992 950.41c48.1 48.43 114.74 78.42 188.39 78.42 146.63 0 265.5-118.87 265.5-265.5 0-73.65-29.99-140.29-78.42-188.39" transform="matrix(.81 0 0 .81 150 110)"/>
<path class="cls-2" d="M1180.37 121.06c-73.65 0-140.29 29.99-188.39 78.42 47.66 47.99 77.11 114.1 77.11 187.08s-29.45 139.09-77.11 187.08c.43.44.87.87 1.3 1.3 47.99-47.66 114.1-77.11 187.08-77.11s139.09 29.45 187.08 77.11c48.43-48.1 78.42-114.74 78.42-188.39 0-146.63-118.87-265.5-265.5-265.5" transform="matrix(.81 0 0 .81 150 110)"/>
<path class="cls-2" d="M1180.37 497.84c-72.98 0-139.09 29.45-187.08 77.11 47.99 47.66 114.1 77.11 187.08 77.11s139.09-29.45 187.08-77.11c-47.99-47.66-114.1-77.11-187.08-77.11" transform="matrix(.81 0 0 .81 150 110)"/>
<path class="cls-3" d="M803.6 121.06c-146.63 0-265.5 118.87-265.5 265.5 0 73.65 29.99 140.29 78.42 188.39 47.99-47.66 114.1-77.11 187.08-77.11s139.09 29.45 187.08 77.11c.44-.43.87-.87 1.3-1.3-47.66-47.99-77.11-114.1-77.11-187.08s29.45-139.09 77.11-187.08c-48.1-48.43-114.74-78.42-188.39-78.42" transform="matrix(.81 0 0 .81 150 110)"/>
<path class="cls-3" d="M991.98 199.48c-47.66 47.99-77.11 114.1-77.11 187.08s29.45 139.09 77.11 187.08c47.66-47.99 77.11-114.1 77.11-187.08s-29.45-139.09-77.11-187.08" transform="matrix(.81 0 0 .81 150 110)"/>
<path class="cls-4" d="M990.68 574.95c-47.99 47.66-114.1 77.11-187.08 77.11s-139.09-29.45-187.08-77.11c-48.43 48.1-78.42 114.74-78.42 188.39 0 146.63 118.87 265.5 265.5 265.5 73.65 0 140.29-29.99 188.39-78.42-47.66-47.99-77.11-114.1-77.11-187.08s29.45-139.09 77.11-187.08c-.43-.44-.87-.87-1.3-1.3" transform="matrix(.81 0 0 .81 150 110)"/>
<path class="cls-4" d="M991.98 576.25c-47.66 47.99-77.11 114.1-77.11 187.08s29.45 139.09 77.11 187.08c47.66-47.99 77.11-114.1 77.11-187.08s-29.45-139.09-77.11-187.08M803.6 497.84c-72.98 0-139.09 29.45-187.08 77.11 47.99 47.66 114.1 77.11 187.08 77.11s139.09-29.45 187.08-77.11c-47.99-47.66-114.1-77.11-187.08-77.11" transform="matrix(.81 0 0 .81 150 110)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1a1 1 0 0 1 1 1v.596a6.501 6.501 0 0 0 2.338 1.463 1.001 1.001 0 0 1-.677 1.883 8.47 8.47 0 0 1-1.66-.816V11h1.9a1 1 0 0 1 0 2H17v5a1 1 0 0 1-2 0v-5H5v5a1 1 0 1 1-2 0v-5H1.1a1 1 0 0 1 0-2h1.9V5.126c-.522.33-1.077.605-1.662.816a1.001 1.001 0 0 1-.678-1.883 6.505 6.505 0 0 0 2.34-1.463V2a1 1 0 0 1 2 0v.79a5.5 5.5 0 0 0 10 .002V2a1 1 0 0 1 1-1Zm-1 5.088a7.499 7.499 0 0 1-4 1.844V11h4V6.088ZM5 11h4V7.932a7.5 7.5 0 0 1-4-1.844V11Z" fill="currentColor"/>
<path d="M16 1a1 1 0 0 1 1 1v.596a6.501 6.501 0 0 0 2.338 1.463 1.001 1.001 0 0 1-.677 1.883 8.47 8.47 0 0 1-1.66-.816V11h1.9a1 1 0 0 1 0 2H17v5a1 1 0 0 1-2 0v-5H5v5a1 1 0 1 1-2 0v-5H1.1a1 1 0 0 1 0-2H3V5.126a8.516 8.516 0 0 1-1.662.816A1.001 1.001 0 0 1 .66 4.059 6.505 6.505 0 0 0 3 2.596V2a1 1 0 0 1 2 0v.79a5.5 5.5 0 0 0 10 .002V2a1 1 0 0 1 1-1Zm-1 5.088a7.499 7.499 0 0 1-4 1.844V11h4V6.088ZM5 11h4V7.932a7.5 7.5 0 0 1-4-1.844V11Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 541 B

+3
View File
@@ -15,6 +15,9 @@ const config: JestConfigWithTsJest = {
],
moduleNameMapper: {
'^jest/(.*)': '<rootDir>/jest/$1',
'^nextjs-routes$': '<rootDir>/jest/mocks/nextjs-routes.js',
'\\.svg$': '<rootDir>/jest/mocks/svg.js',
'^@uidotdev/usehooks$': '<rootDir>/jest/mocks/usehooks.js',
},
modulePathIgnorePatterns: [
'node_modules_linux',
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
route: jest.fn((opts) => {
const pathname = opts?.pathname;
const query = opts?.query || {};
if (pathname === '/address/[hash]') {
return `/address/${ query.hash || 'test-hash' }`;
}
if (pathname === '/tx/[hash]') {
return `/tx/${ query.hash || 'test-hash' }`;
}
if (pathname === '/clusters/[name]') {
return `/clusters/${ query.name || 'test-cluster' }`;
}
return pathname || '/';
}),
};
+1
View File
@@ -0,0 +1 @@
module.exports = 'test-file-stub';
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
useClickAway: jest.fn(() => jest.fn()),
useEventListener: jest.fn(),
useLocalStorage: jest.fn(() => [ '', jest.fn() ]),
useSessionStorage: jest.fn(() => [ '', jest.fn() ]),
useToggle: jest.fn(() => [ false, jest.fn() ]),
useDebounce: jest.fn((value) => value),
useThrottle: jest.fn((value) => value),
usePrevious: jest.fn(),
useCounter: jest.fn(() => ({ count: 0, increment: jest.fn(), decrement: jest.fn(), reset: jest.fn() })),
useCopyToClipboard: jest.fn(() => [ '', jest.fn() ]),
};
+6
View File
@@ -0,0 +1,6 @@
import { ADDRESS_REGEXP } from 'toolkit/utils/regexp';
export function isEvmAddress(address: string): boolean {
if (!address) return false;
return ADDRESS_REGEXP.test(address.trim());
}
+6
View File
@@ -4,6 +4,8 @@ import type { AdminApiResourceName, AdminApiResourcePayload } from './services/a
import { ADMIN_API_RESOURCES } from './services/admin';
import { BENS_API_RESOURCES } from './services/bens';
import type { BensApiResourceName, BensApiResourcePayload, BensApiPaginationFilters, BensApiPaginationSorting } from './services/bens';
import { CLUSTERS_API_RESOURCES } from './services/clusters';
import type { ClustersApiResourceName, ClustersApiResourcePayload, ClustersApiPaginationFilters, ClustersApiPaginationSorting } from './services/clusters';
import { CONTRACT_INFO_API_RESOURCES } from './services/contractInfo';
import type { ContractInfoApiPaginationFilters, ContractInfoApiResourceName, ContractInfoApiResourcePayload } from './services/contractInfo';
import { GENERAL_API_RESOURCES } from './services/general';
@@ -32,6 +34,7 @@ import type { ZetaChainApiPaginationFilters, ZetaChainApiResourceName, ZetaChain
export const RESOURCES = {
admin: ADMIN_API_RESOURCES,
bens: BENS_API_RESOURCES,
clusters: CLUSTERS_API_RESOURCES,
contractInfo: CONTRACT_INFO_API_RESOURCES,
general: GENERAL_API_RESOURCES,
metadata: METADATA_API_RESOURCES,
@@ -56,6 +59,7 @@ export type ResourcePath = string;
export type ResourcePayload<R extends ResourceName> =
R extends AdminApiResourceName ? AdminApiResourcePayload<R> :
R extends BensApiResourceName ? BensApiResourcePayload<R> :
R extends ClustersApiResourceName ? ClustersApiResourcePayload<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload<R> :
R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> :
R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> :
@@ -93,6 +97,7 @@ export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>;
/* eslint-disable @stylistic/indent */
export type PaginationFilters<R extends ResourceName> =
R extends BensApiResourceName ? BensApiPaginationFilters<R> :
R extends ClustersApiResourceName ? ClustersApiPaginationFilters :
R extends GeneralApiResourceName ? GeneralApiPaginationFilters<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters<R> :
R extends MultichainApiResourceName ? MultichainApiPaginationFilters<R> :
@@ -106,6 +111,7 @@ export const SORTING_FIELDS = [ 'sort', 'order' ];
/* eslint-disable @stylistic/indent */
export type PaginationSorting<R extends ResourceName> =
R extends BensApiResourceName ? BensApiPaginationSorting<R> :
R extends ClustersApiResourceName ? ClustersApiPaginationSorting :
R extends GeneralApiResourceName ? GeneralApiPaginationSorting<R> :
never;
/* eslint-enable @stylistic/indent */
+57
View File
@@ -0,0 +1,57 @@
import type { ApiResource } from '../types';
import type {
ClustersByAddressResponse,
ClusterByNameResponse,
ClustersLeaderboardResponse,
ClustersDirectoryResponse,
ClustersByAddressQueryParams,
ClusterByNameQueryParams,
ClustersLeaderboardQueryParams,
ClustersDirectoryQueryParams,
ClusterByIdQueryParams,
ClusterByIdResponse,
} from 'types/api/clusters';
export const CLUSTERS_API_RESOURCES = {
get_clusters_by_address: {
path: '/v1/trpc/names.getNamesByOwnerAddress',
pathParams: [],
},
get_cluster_by_name: {
path: '/v1/trpc/names.get',
pathParams: [],
},
get_cluster_by_id: {
path: '/v1/trpc/clusters.getClusterById',
pathParams: [],
},
get_leaderboard: {
path: '/v1/trpc/names.leaderboard',
pathParams: [],
},
get_directory: {
path: '/v1/trpc/names.search',
pathParams: [],
},
} satisfies Record<string, ApiResource>;
export type ClustersApiResourceName = `clusters:${ keyof typeof CLUSTERS_API_RESOURCES }`;
export type ClustersApiResourcePayload<R extends ClustersApiResourceName> =
R extends 'clusters:get_clusters_by_address' ? ClustersByAddressResponse :
R extends 'clusters:get_cluster_by_name' ? ClusterByNameResponse :
R extends 'clusters:get_cluster_by_id' ? ClusterByIdResponse :
R extends 'clusters:get_leaderboard' ? ClustersLeaderboardResponse :
R extends 'clusters:get_directory' ? ClustersDirectoryResponse :
never;
export type ClustersApiQueryParams<R extends ClustersApiResourceName> =
R extends 'clusters:get_clusters_by_address' ? ClustersByAddressQueryParams :
R extends 'clusters:get_cluster_by_name' ? ClusterByNameQueryParams :
R extends 'clusters:get_cluster_by_id' ? ClusterByIdQueryParams :
R extends 'clusters:get_leaderboard' ? ClustersLeaderboardQueryParams :
R extends 'clusters:get_directory' ? ClustersDirectoryQueryParams :
never;
export type ClustersApiPaginationFilters = never;
export type ClustersApiPaginationSorting = never;
+3 -12
View File
@@ -1,16 +1,7 @@
export type ApiName =
'general' |
'admin' |
'bens' |
'contractInfo' |
'metadata' |
'multichain' |
'rewards' |
'stats' |
'tac' |
'userOps' |
'visualize' |
'zetachain';
'general' | 'admin' | 'bens' | 'contractInfo' | 'clusters' |
'metadata' | 'multichain' | 'rewards' | 'stats' | 'tac' |
'userOps' | 'visualize' | 'zetachain';
export interface ApiResource {
path: string;
+63
View File
@@ -0,0 +1,63 @@
import {
shouldShowClearButton,
shouldDisableViewToggle,
getSearchPlaceholder,
shouldShowActionBar,
} from './actionBarUtils';
describe('actionBarUtils', () => {
describe('shouldShowClearButton', () => {
it('should return true for non-empty search values', () => {
expect(shouldShowClearButton('test')).toBe(true);
expect(shouldShowClearButton('a')).toBe(true);
expect(shouldShowClearButton('cluster-name')).toBe(true);
});
it('should return false for empty search values', () => {
expect(shouldShowClearButton('')).toBe(false);
});
it('should return true for whitespace (button should be visible)', () => {
expect(shouldShowClearButton(' ')).toBe(true);
expect(shouldShowClearButton(' ')).toBe(true);
});
});
describe('shouldDisableViewToggle', () => {
it('should return true when loading', () => {
expect(shouldDisableViewToggle(true)).toBe(true);
});
it('should return false when not loading', () => {
expect(shouldDisableViewToggle(false)).toBe(false);
});
});
describe('getSearchPlaceholder', () => {
it('should return consistent placeholder text', () => {
const placeholder = getSearchPlaceholder();
expect(placeholder).toBe('Search clusters by name or EVM address');
});
it('should return same result on multiple calls', () => {
const first = getSearchPlaceholder();
const second = getSearchPlaceholder();
expect(first).toBe(second);
});
});
describe('shouldShowActionBar', () => {
it('should return true on desktop regardless of pagination', () => {
expect(shouldShowActionBar(false, true)).toBe(true);
expect(shouldShowActionBar(true, true)).toBe(true);
});
it('should return true on mobile when pagination is visible', () => {
expect(shouldShowActionBar(true, false)).toBe(true);
});
it('should return false on mobile when pagination is not visible', () => {
expect(shouldShowActionBar(false, false)).toBe(false);
});
});
});
+15
View File
@@ -0,0 +1,15 @@
export function shouldShowClearButton(searchValue: string): boolean {
return searchValue.length > 0;
}
export function shouldDisableViewToggle(isLoading: boolean): boolean {
return isLoading;
}
export function getSearchPlaceholder(): string {
return 'Search clusters by name or EVM address';
}
export function shouldShowActionBar(paginationVisible: boolean, isDesktop: boolean): boolean {
return isDesktop || paginationVisible;
}
+204
View File
@@ -0,0 +1,204 @@
import {
filterOwnedClusters,
getTotalRecordsDisplay,
getClusterLabel,
getClustersToShow,
getGridRows,
hasMoreClusters,
type ClusterData,
} from './clustersUtils';
describe('clustersUtils', () => {
const mockClusters: Array<ClusterData> = [
{
name: 'cluster1',
owner: '0x1234567890123456789012345678901234567890',
totalWeiAmount: '1000000000000000000',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
updatedBy: 'user1',
isTestnet: false,
clusterId: 'id1',
expiresAt: null,
},
{
name: 'cluster2',
owner: '0xABCDEF1234567890123456789012345678901234',
totalWeiAmount: '2000000000000000000',
createdAt: '2023-01-02',
updatedAt: '2023-01-02',
updatedBy: 'user2',
isTestnet: false,
clusterId: 'id2',
expiresAt: null,
},
{
name: 'cluster3',
owner: '0x1234567890123456789012345678901234567890',
totalWeiAmount: '3000000000000000000',
createdAt: '2023-01-03',
updatedAt: '2023-01-03',
updatedBy: 'user3',
isTestnet: false,
clusterId: 'id3',
expiresAt: null,
},
];
describe('filterOwnedClusters', () => {
it('should filter clusters by owner address', () => {
const result = filterOwnedClusters(mockClusters, '0x1234567890123456789012345678901234567890');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('cluster1');
expect(result[1].name).toBe('cluster3');
});
it('should handle case insensitive address matching', () => {
const result = filterOwnedClusters(mockClusters, '0x1234567890123456789012345678901234567890'.toUpperCase());
expect(result).toHaveLength(2);
expect(result[0].name).toBe('cluster1');
expect(result[1].name).toBe('cluster3');
});
it('should return empty array for non-matching address', () => {
const result = filterOwnedClusters(mockClusters, '0x9999999999999999999999999999999999999999');
expect(result).toHaveLength(0);
});
it('should filter out clusters without owner', () => {
const clustersWithoutOwner = [
...mockClusters,
{
name: 'cluster4',
owner: null as unknown as string,
totalWeiAmount: '4000000000000000000',
createdAt: '2023-01-04',
updatedAt: '2023-01-04',
updatedBy: 'user4',
isTestnet: false,
clusterId: 'id4',
expiresAt: null,
},
];
const result = filterOwnedClusters(clustersWithoutOwner, '0x1234567890123456789012345678901234567890');
expect(result).toHaveLength(2);
});
});
describe('getTotalRecordsDisplay', () => {
it('should return exact count for numbers 40 and below', () => {
expect(getTotalRecordsDisplay(1)).toBe('1');
expect(getTotalRecordsDisplay(10)).toBe('10');
expect(getTotalRecordsDisplay(40)).toBe('40');
});
it('should return "40+" for numbers above 40', () => {
expect(getTotalRecordsDisplay(41)).toBe('40+');
expect(getTotalRecordsDisplay(100)).toBe('40+');
expect(getTotalRecordsDisplay(999)).toBe('40+');
});
it('should handle edge case of 0', () => {
expect(getTotalRecordsDisplay(0)).toBe('0');
});
});
describe('getClusterLabel', () => {
it('should return singular for count of 1', () => {
expect(getClusterLabel(1)).toBe('Cluster');
});
it('should return plural for count greater than 1', () => {
expect(getClusterLabel(2)).toBe('Clusters');
expect(getClusterLabel(10)).toBe('Clusters');
expect(getClusterLabel(100)).toBe('Clusters');
});
it('should return singular for count of 0', () => {
expect(getClusterLabel(0)).toBe('Cluster');
});
});
describe('getClustersToShow', () => {
it('should return first 10 items by default', () => {
const manyClusters = Array(15).fill(null).map((_, i) => ({
...mockClusters[0],
name: `cluster${ i }`,
clusterId: `id${ i }`,
}));
const result = getClustersToShow(manyClusters);
expect(result).toHaveLength(10);
expect(result[0].name).toBe('cluster0');
expect(result[9].name).toBe('cluster9');
});
it('should respect custom maxItems parameter', () => {
const result = getClustersToShow(mockClusters, 2);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('cluster1');
expect(result[1].name).toBe('cluster2');
});
it('should return all items if fewer than maxItems', () => {
const result = getClustersToShow(mockClusters, 10);
expect(result).toHaveLength(3);
expect(result).toEqual(mockClusters);
});
it('should handle empty array', () => {
const result = getClustersToShow([]);
expect(result).toHaveLength(0);
});
});
describe('getGridRows', () => {
it('should return the item count if less than maxRows', () => {
expect(getGridRows(3)).toBe(3);
expect(getGridRows(1)).toBe(1);
});
it('should return maxRows if item count exceeds it', () => {
expect(getGridRows(10)).toBe(5);
expect(getGridRows(20)).toBe(5);
});
it('should respect custom maxRows parameter', () => {
expect(getGridRows(10, 3)).toBe(3);
expect(getGridRows(2, 3)).toBe(2);
});
it('should handle edge case of 0 items', () => {
expect(getGridRows(0)).toBe(0);
});
});
describe('hasMoreClusters', () => {
it('should return true when total count exceeds display count', () => {
expect(hasMoreClusters(15, 10)).toBe(true);
expect(hasMoreClusters(11, 10)).toBe(true);
});
it('should return false when total count equals display count', () => {
expect(hasMoreClusters(10, 10)).toBe(false);
});
it('should return false when total count is less than display count', () => {
expect(hasMoreClusters(5, 10)).toBe(false);
});
it('should handle edge cases', () => {
expect(hasMoreClusters(0, 0)).toBe(false);
expect(hasMoreClusters(1, 0)).toBe(true);
});
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { ClustersByAddressResponse } from 'types/api/clusters';
export type ClusterData = ClustersByAddressResponse['result']['data'][0];
export function filterOwnedClusters(clusters: Array<ClusterData>, ownerAddress: string): Array<ClusterData> {
return clusters.filter((cluster) =>
cluster.owner && cluster.owner.toLowerCase() === ownerAddress.toLowerCase(),
);
}
export function getTotalRecordsDisplay(count: number): string {
return count > 40 ? '40+' : count.toString();
}
export function getClusterLabel(count: number): string {
return count > 1 ? 'Clusters' : 'Cluster';
}
export function getClustersToShow(clusters: Array<ClusterData>, maxItems: number = 10): Array<ClusterData> {
return clusters.slice(0, maxItems);
}
export function getGridRows(itemCount: number, maxRows: number = 5): number {
return Math.min(itemCount, maxRows);
}
export function hasMoreClusters(totalCount: number, displayCount: number): boolean {
return totalCount > displayCount;
}
+39
View File
@@ -0,0 +1,39 @@
import { isEvmAddress } from 'lib/address/isEvmAddress';
import { detectInputType } from './detectInputType';
describe('detectInputType', () => {
it('should detect EVM address format', () => {
expect(detectInputType('0x1234567890123456789012345678901234567890')).toBe('address');
});
it('should detect cluster name format', () => {
expect(detectInputType('test-cluster')).toBe('cluster_name');
});
});
describe('isEvmAddress', () => {
it('should return true for valid EVM address', () => {
expect(isEvmAddress('0x1234567890123456789012345678901234567890')).toBe(true);
expect(isEvmAddress('0xabcdef1234567890123456789012345678901234')).toBe(true);
expect(isEvmAddress('0xABCDEF1234567890123456789012345678901234')).toBe(true);
});
it('should return false for invalid EVM address', () => {
expect(isEvmAddress('0x123')).toBe(false);
expect(isEvmAddress('123456789012345678901234567890123456789')).toBe(false);
expect(isEvmAddress('0xGGGGGG1234567890123456789012345678901234')).toBe(false);
expect(isEvmAddress('0x12345678901234567890123456789012345678901')).toBe(false);
});
it('should return false for empty or null input', () => {
expect(isEvmAddress('')).toBe(false);
expect(isEvmAddress(null as unknown as string)).toBe(false);
expect(isEvmAddress(undefined as unknown as string)).toBe(false);
});
it('should handle addresses with extra whitespace', () => {
expect(isEvmAddress(' 0x1234567890123456789012345678901234567890 ')).toBe(true);
expect(isEvmAddress(' 0x123 ')).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { ADDRESS_REGEXP } from 'toolkit/utils/regexp';
export type InputType = 'address' | 'cluster_name';
export function detectInputType(input: string): InputType {
if (!input || input.trim().length === 0) {
return 'cluster_name';
}
const trimmedInput = input.trim();
if (ADDRESS_REGEXP.test(trimmedInput)) {
return 'address';
}
return 'cluster_name';
}
+331
View File
@@ -0,0 +1,331 @@
import { ClustersOrderBy } from 'types/api/clusters';
import type { ClustersDirectoryObject } from 'types/api/clusters';
import {
getViewModeOrderBy,
shouldShowDirectoryView,
transformLeaderboardData,
transformAddressDataToDirectory,
transformFullDirectoryData,
applyDirectoryPagination,
calculateHasNextPage,
isValidViewMode,
getDefaultViewMode,
getCurrentDataLength,
} from './pageUtils';
describe('pageUtils', () => {
describe('getViewModeOrderBy', () => {
it('should return RANK_ASC for leaderboard view regardless of search', () => {
expect(getViewModeOrderBy('leaderboard', false)).toBe(ClustersOrderBy.RANK_ASC);
expect(getViewModeOrderBy('leaderboard', true)).toBe(ClustersOrderBy.RANK_ASC);
});
it('should return NAME_ASC for directory view with search term', () => {
expect(getViewModeOrderBy('directory', true)).toBe(ClustersOrderBy.NAME_ASC);
});
it('should return CREATED_AT_DESC for directory view without search term', () => {
expect(getViewModeOrderBy('directory', false)).toBe(ClustersOrderBy.CREATED_AT_DESC);
});
});
describe('shouldShowDirectoryView', () => {
it('should return true for directory view mode', () => {
expect(shouldShowDirectoryView('directory', false)).toBe(true);
expect(shouldShowDirectoryView('directory', true)).toBe(true);
});
it('should return true for leaderboard mode with search term', () => {
expect(shouldShowDirectoryView('leaderboard', true)).toBe(true);
});
it('should return false for leaderboard mode without search term', () => {
expect(shouldShowDirectoryView('leaderboard', false)).toBe(false);
});
});
describe('transformLeaderboardData', () => {
const mockLeaderboardData = {
result: {
data: [
{ name: 'cluster1', rank: 1 },
{ name: 'cluster2', rank: 2 },
],
},
};
it('should return empty array when showDirectoryView is true', () => {
expect(transformLeaderboardData(mockLeaderboardData, true)).toEqual([]);
});
it('should return empty array when data is null', () => {
expect(transformLeaderboardData(null, false)).toEqual([]);
});
it('should return transformed data when valid', () => {
const result = transformLeaderboardData(mockLeaderboardData, false);
expect(result).toEqual([
{ name: 'cluster1', rank: 1 },
{ name: 'cluster2', rank: 2 },
]);
});
it('should return empty array for invalid data structure', () => {
expect(transformLeaderboardData({ invalid: 'data' }, false)).toEqual([]);
expect(transformLeaderboardData({ result: { data: 'not-array' } }, false)).toEqual([]);
});
});
describe('transformAddressDataToDirectory', () => {
const mockAddressData = [
{
name: 'test-cluster',
isTestnet: false,
createdAt: '2023-01-01',
owner: '0x123',
totalWeiAmount: '1000',
updatedAt: '2023-01-02',
updatedBy: '0x456',
clusterId: 'test-cluster-id',
expiresAt: '2024-01-01',
},
];
const mockClusterDetails = {
result: {
data: {
wallets: [
{ chainIds: [ '1', '137' ] },
{ chainIds: [ '1', '56' ] },
],
},
},
};
it('should transform address data with unique chain IDs', () => {
const result = transformAddressDataToDirectory(mockAddressData, mockClusterDetails);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
name: 'test-cluster',
isTestnet: false,
createdAt: '2023-01-01',
owner: '0x123',
totalWeiAmount: '1000',
updatedAt: '2023-01-02',
updatedBy: '0x456',
chainIds: [ '1', '137', '56' ],
});
});
it('should handle missing cluster details', () => {
const result = transformAddressDataToDirectory(mockAddressData, null);
expect(result[0].chainIds).toEqual([]);
});
it('should handle empty wallets array', () => {
const emptyDetails = { result: { data: { wallets: [] } } };
const result = transformAddressDataToDirectory(mockAddressData, emptyDetails);
expect(result[0].chainIds).toEqual([]);
});
});
describe('transformFullDirectoryData', () => {
it('should return empty array when showDirectoryView is false', () => {
const result = transformFullDirectoryData({}, {}, 'address', false);
expect(result).toEqual([]);
});
it('should return empty array when data is null', () => {
const result = transformFullDirectoryData(null, {}, 'address', true);
expect(result).toEqual([]);
});
it('should transform address-type data', () => {
const mockData = {
result: {
data: [ { name: 'cluster1', owner: '0x123' } ],
},
};
const mockDetails = {
result: { data: { wallets: [ { chainIds: [ '1' ] } ] } },
};
const result = transformFullDirectoryData(mockData, mockDetails, 'address', true);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('cluster1');
});
it('should transform cluster_name-type data', () => {
const mockData = {
result: {
data: {
items: [ { name: 'cluster1' }, { name: 'cluster2' } ],
},
},
};
const result = transformFullDirectoryData(mockData, {}, 'cluster_name', true);
expect(result).toEqual([ { name: 'cluster1' }, { name: 'cluster2' } ]);
});
});
describe('applyDirectoryPagination', () => {
const mockData = Array.from({ length: 100 }, (_, i) => ({ name: `cluster${ i }` })) as Array<ClustersDirectoryObject>;
it('should apply pagination for address input type', () => {
const result = applyDirectoryPagination(mockData, 'address', 2, 20);
expect(result).toHaveLength(20);
expect(result[0].name).toBe('cluster20');
expect(result[19].name).toBe('cluster39');
});
it('should return all data for cluster_name input type', () => {
const result = applyDirectoryPagination(mockData, 'cluster_name', 2, 20);
expect(result).toHaveLength(100);
expect(result[0].name).toBe('cluster0');
});
it('should handle last page correctly', () => {
const result = applyDirectoryPagination(mockData, 'address', 5, 20);
expect(result).toHaveLength(20);
expect(result[0].name).toBe('cluster80');
});
it('should handle page beyond data length', () => {
const result = applyDirectoryPagination(mockData, 'address', 10, 20);
expect(result).toHaveLength(0);
});
});
describe('calculateHasNextPage', () => {
const mockDirectoryData = {
result: {
data: {
total: 100,
},
},
};
it('should return true for address type with more data', () => {
const result = calculateHasNextPage(
{},
0,
200,
true,
'address',
2,
false,
50,
);
expect(result).toBe(true);
});
it('should return false for address type at end', () => {
const result = calculateHasNextPage(
{},
0,
100,
true,
'address',
2,
false,
50,
);
expect(result).toBe(false);
});
it('should return false for cluster_name type with search term', () => {
const result = calculateHasNextPage(
mockDirectoryData,
0,
0,
true,
'cluster_name',
1,
true,
50,
);
expect(result).toBe(false);
});
it('should return true for cluster_name type without search and more pages', () => {
const result = calculateHasNextPage(
mockDirectoryData,
0,
0,
true,
'cluster_name',
1,
false,
50,
);
expect(result).toBe(true);
});
it('should return true for leaderboard with full page', () => {
const result = calculateHasNextPage(
{},
50,
0,
false,
'cluster_name',
1,
false,
50,
);
expect(result).toBe(true);
});
it('should return false for leaderboard with partial page', () => {
const result = calculateHasNextPage(
{},
25,
0,
false,
'cluster_name',
1,
false,
50,
);
expect(result).toBe(false);
});
});
describe('isValidViewMode', () => {
it('should return true for valid view modes', () => {
expect(isValidViewMode('leaderboard')).toBe(true);
expect(isValidViewMode('directory')).toBe(true);
});
it('should return false for invalid view modes', () => {
expect(isValidViewMode('invalid')).toBe(false);
expect(isValidViewMode('')).toBe(false);
expect(isValidViewMode('grid')).toBe(false);
});
});
describe('getDefaultViewMode', () => {
it('should return directory as default', () => {
expect(getDefaultViewMode()).toBe('directory');
});
});
describe('getCurrentDataLength', () => {
it('should return directory data length when showing directory view', () => {
expect(getCurrentDataLength(true, 25, 50)).toBe(25);
});
it('should return leaderboard data length when showing leaderboard view', () => {
expect(getCurrentDataLength(false, 25, 50)).toBe(50);
});
it('should handle zero lengths', () => {
expect(getCurrentDataLength(true, 0, 10)).toBe(0);
expect(getCurrentDataLength(false, 10, 0)).toBe(0);
});
});
});
+161
View File
@@ -0,0 +1,161 @@
import { ClustersOrderBy } from 'types/api/clusters';
import type { ClustersLeaderboardObject, ClustersDirectoryObject, ClustersByAddressObject } from 'types/api/clusters';
export type ViewMode = 'leaderboard' | 'directory';
export type InputType = 'address' | 'cluster_name';
export function getViewModeOrderBy(viewMode: ViewMode, hasSearchTerm: boolean): ClustersOrderBy {
if (viewMode === 'leaderboard') return ClustersOrderBy.RANK_ASC;
if (hasSearchTerm) return ClustersOrderBy.NAME_ASC;
return ClustersOrderBy.CREATED_AT_DESC;
}
export function shouldShowDirectoryView(viewMode: ViewMode, hasSearchTerm: boolean): boolean {
return viewMode === 'directory' || hasSearchTerm;
}
export function transformLeaderboardData(
data: unknown,
showDirectoryView: boolean,
): Array<ClustersLeaderboardObject> {
if (!data || showDirectoryView) return [];
if (data && typeof data === 'object' && 'result' in data) {
const result = (data as Record<string, unknown>).result;
if (result && typeof result === 'object' && 'data' in result && Array.isArray(result.data)) {
return result.data as Array<ClustersLeaderboardObject>;
}
}
return [];
}
export function transformAddressDataToDirectory(
addressData: Array<ClustersByAddressObject>,
clusterDetails: unknown,
): Array<ClustersDirectoryObject> {
const clusterDetailsData = clusterDetails &&
typeof clusterDetails === 'object' &&
'result' in clusterDetails &&
clusterDetails.result &&
typeof clusterDetails.result === 'object' &&
'data' in clusterDetails.result ? clusterDetails.result.data : null;
const allChainIds = clusterDetailsData &&
typeof clusterDetailsData === 'object' &&
'wallets' in clusterDetailsData &&
Array.isArray(clusterDetailsData.wallets) ?
clusterDetailsData.wallets.flatMap(
(wallet: unknown) => {
if (wallet && typeof wallet === 'object' && 'chainIds' in wallet && Array.isArray(wallet.chainIds)) {
return wallet.chainIds as Array<string>;
}
return [];
},
) : [];
const uniqueChainIds = [ ...new Set(allChainIds) ] as Array<string>;
return addressData.map((item) => ({
name: item.name,
isTestnet: item.isTestnet,
createdAt: item.createdAt,
owner: item.owner,
totalWeiAmount: item.totalWeiAmount,
updatedAt: item.updatedAt,
updatedBy: item.updatedBy,
chainIds: uniqueChainIds,
}));
}
export function transformFullDirectoryData(
data: unknown,
clusterDetails: unknown,
inputType: InputType,
showDirectoryView: boolean,
): Array<ClustersDirectoryObject> {
if (!showDirectoryView || !data) return [];
if (inputType === 'address') {
const addressData = data &&
typeof data === 'object' &&
'result' in data &&
data.result &&
typeof data.result === 'object' &&
'data' in data.result ? data.result.data as Array<ClustersByAddressObject> : null;
if (addressData && Array.isArray(addressData)) {
return transformAddressDataToDirectory(addressData, clusterDetails);
}
} else {
const apiData = data &&
typeof data === 'object' &&
'result' in data &&
data.result &&
typeof data.result === 'object' &&
'data' in data.result ? data.result.data : null;
if (apiData && typeof apiData === 'object' && 'items' in apiData && Array.isArray(apiData.items)) {
return apiData.items as Array<ClustersDirectoryObject>;
}
}
return [];
}
export function applyDirectoryPagination(
fullDirectoryData: Array<ClustersDirectoryObject>,
inputType: InputType,
page: number,
limit = 50,
): Array<ClustersDirectoryObject> {
if (inputType === 'address') {
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
return fullDirectoryData.slice(startIndex, endIndex);
}
return fullDirectoryData;
}
export function calculateHasNextPage(
data: unknown,
leaderboardDataLength: number,
fullDirectoryDataLength: number,
showDirectoryView: boolean,
inputType: InputType,
page: number,
hasSearchTerm: boolean,
limit = 50,
): boolean {
if (showDirectoryView) {
if (inputType === 'address') {
return page * limit < fullDirectoryDataLength;
} else {
if (hasSearchTerm) return false;
const apiData = data &&
typeof data === 'object' &&
'result' in data &&
data.result &&
typeof data.result === 'object' &&
'data' in data.result ? data.result.data : null;
if (apiData && typeof apiData === 'object' && 'total' in apiData && typeof apiData.total === 'number') {
return (page * limit) < apiData.total;
}
return false;
}
}
return leaderboardDataLength === limit;
}
export function isValidViewMode(value: string): value is ViewMode {
return value === 'leaderboard' || value === 'directory';
}
export function getDefaultViewMode(): ViewMode {
return 'directory';
}
export function getCurrentDataLength(
showDirectoryView: boolean,
directoryDataLength: number,
leaderboardDataLength: number,
): number {
return showDirectoryView ? directoryDataLength : leaderboardDataLength;
}
+72
View File
@@ -0,0 +1,72 @@
import { useAddressClusters } from './useAddressClusters';
jest.mock('lib/api/useApiQuery', () => ({
__esModule: true,
'default': jest.fn(),
}));
jest.mock('configs/app', () => ({
features: {
clusters: {
isEnabled: true,
},
},
}));
const mockUseApiQuery = require('lib/api/useApiQuery').default;
describe('useAddressClusters', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseApiQuery.mockReturnValue({ data: null, isLoading: false });
});
it('should call API with correct parameters', () => {
const addressHash = '0x1234567890123456789012345678901234567890';
useAddressClusters(addressHash);
expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_clusters_by_address', {
queryParams: {
input: JSON.stringify({
address: addressHash,
}),
},
queryOptions: {
enabled: true,
},
});
});
it('should be disabled when addressHash is empty', () => {
useAddressClusters('');
expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_clusters_by_address', {
queryParams: {
input: JSON.stringify({
address: '',
}),
},
queryOptions: {
enabled: false,
},
});
});
it('should handle isEnabled parameter', () => {
const addressHash = '0x1234567890123456789012345678901234567890';
useAddressClusters(addressHash, false);
expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_clusters_by_address', {
queryParams: {
input: JSON.stringify({
address: addressHash,
}),
},
queryOptions: {
enabled: false,
},
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
export function useAddressClusters(addressHash: string, isEnabled: boolean = true) {
return useApiQuery('clusters:get_clusters_by_address', {
queryParams: {
input: JSON.stringify({
address: addressHash,
}),
},
queryOptions: {
enabled: Boolean(addressHash) && config.features.clusters.isEnabled && isEnabled,
},
});
}
+199
View File
@@ -0,0 +1,199 @@
import { renderHook, act } from 'jest/lib';
import getQueryParamString from 'lib/router/getQueryParamString';
import { useQueryParams } from 'lib/router/useQueryParams';
import { useClusterPagination } from '../clusters/useClusterPagination';
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
jest.mock('lib/router/useQueryParams');
jest.mock('lib/router/getQueryParamString');
const { useRouter } = require('next/router');
const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
const mockUseQueryParams = useQueryParams as jest.MockedFunction<typeof useQueryParams>;
const mockGetQueryParamString = getQueryParamString as jest.MockedFunction<typeof getQueryParamString>;
describe('useClusterPagination', () => {
const mockUpdateQuery = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseRouter.mockReturnValue({
query: {},
} as unknown as ReturnType<typeof useRouter>);
mockUseQueryParams.mockReturnValue({
updateQuery: mockUpdateQuery,
});
});
describe('page calculation', () => {
it('should default to page 1 when no page param', () => {
mockGetQueryParamString.mockReturnValue('');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.page).toBe(1);
expect(result.current.pagination.page).toBe(1);
});
it('should parse page from query param', () => {
mockGetQueryParamString.mockReturnValue('3');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.page).toBe(3);
expect(result.current.pagination.page).toBe(3);
});
it('should handle invalid page param gracefully', () => {
mockGetQueryParamString.mockReturnValue('invalid');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.page).toBeNaN();
});
});
describe('navigation functions', () => {
beforeEach(() => {
mockGetQueryParamString.mockReturnValue('2');
});
it('should increment page on next click', () => {
const { result } = renderHook(() => useClusterPagination(true, false));
act(() => {
result.current.pagination.onNextPageClick();
});
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: '3' });
});
it('should decrement page on prev click', () => {
const { result } = renderHook(() => useClusterPagination(true, false));
act(() => {
result.current.pagination.onPrevPageClick();
});
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: undefined });
});
it('should handle prev click from page 3', () => {
mockGetQueryParamString.mockReturnValue('3');
const { result } = renderHook(() => useClusterPagination(true, false));
act(() => {
result.current.pagination.onPrevPageClick();
});
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: '2' });
});
it('should reset page to undefined', () => {
const { result } = renderHook(() => useClusterPagination(true, false));
act(() => {
result.current.pagination.resetPage();
});
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: undefined });
});
});
describe('pagination state', () => {
it('should set hasPages true when page > 1', () => {
mockGetQueryParamString.mockReturnValue('2');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.pagination.hasPages).toBe(true);
});
it('should set hasPages false when page = 1', () => {
mockGetQueryParamString.mockReturnValue('1');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.pagination.hasPages).toBe(false);
});
it('should set canGoBackwards true when page > 1', () => {
mockGetQueryParamString.mockReturnValue('2');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.pagination.canGoBackwards).toBe(true);
});
it('should set canGoBackwards false when page = 1', () => {
mockGetQueryParamString.mockReturnValue('1');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.pagination.canGoBackwards).toBe(false);
});
it('should pass through hasNextPage prop', () => {
mockGetQueryParamString.mockReturnValue('1');
const { result } = renderHook(() => useClusterPagination(false, false));
expect(result.current.pagination.hasNextPage).toBe(false);
});
it('should pass through isLoading prop', () => {
mockGetQueryParamString.mockReturnValue('1');
const { result } = renderHook(() => useClusterPagination(true, true));
expect(result.current.pagination.isLoading).toBe(true);
});
});
describe('pagination visibility', () => {
it('should be visible when page > 1', () => {
mockGetQueryParamString.mockReturnValue('2');
const { result } = renderHook(() => useClusterPagination(false, false));
expect(result.current.pagination.isVisible).toBe(true);
});
it('should be visible when hasNextPage is true', () => {
mockGetQueryParamString.mockReturnValue('1');
const { result } = renderHook(() => useClusterPagination(true, false));
expect(result.current.pagination.isVisible).toBe(true);
});
it('should not be visible when page = 1 and no next page', () => {
mockGetQueryParamString.mockReturnValue('1');
const { result } = renderHook(() => useClusterPagination(false, false));
expect(result.current.pagination.isVisible).toBe(false);
});
});
describe('function stability', () => {
it('should not recreate functions when dependencies do not change', () => {
mockGetQueryParamString.mockReturnValue('2');
const { result, rerender } = renderHook(() => useClusterPagination(true, false));
const firstOnNext = result.current.pagination.onNextPageClick;
const firstOnPrev = result.current.pagination.onPrevPageClick;
const firstReset = result.current.pagination.resetPage;
rerender();
expect(result.current.pagination.onNextPageClick).toBe(firstOnNext);
expect(result.current.pagination.onPrevPageClick).toBe(firstOnPrev);
expect(result.current.pagination.resetPage).toBe(firstReset);
});
});
});
+42
View File
@@ -0,0 +1,42 @@
import { useRouter } from 'next/router';
import { useCallback, useMemo } from 'react';
import type { PaginationParams } from 'ui/shared/pagination/types';
import getQueryParamString from 'lib/router/getQueryParamString';
import { useQueryParams } from 'lib/router/useQueryParams';
export function useClusterPagination(hasNextPage: boolean, isLoading: boolean) {
const router = useRouter();
const { updateQuery } = useQueryParams();
const page = parseInt(getQueryParamString(router.query.page) || '1', 10);
const onNextPageClick = useCallback(() => {
updateQuery({ page: (page + 1).toString() });
}, [ updateQuery, page ]);
const onPrevPageClick = useCallback(() => {
updateQuery({ page: page === 2 ? undefined : (page - 1).toString() });
}, [ updateQuery, page ]);
const resetPage = useCallback(() => {
updateQuery({ page: undefined });
}, [ updateQuery ]);
const pagination: PaginationParams = useMemo(() => ({
page,
onNextPageClick,
onPrevPageClick,
resetPage,
hasPages: page > 1,
hasNextPage,
canGoBackwards: page > 1,
isLoading,
isVisible: page > 1 || hasNextPage,
}), [ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, isLoading ]);
return {
page,
pagination,
};
}
+48
View File
@@ -0,0 +1,48 @@
import { useRouter } from 'next/router';
import { renderHook } from 'jest/lib';
import { useClusterSearch } from './useClusterSearch';
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
describe('useClusterSearch', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return search term from router query', () => {
mockUseRouter.mockReturnValue({
query: { q: 'test-search' },
} as unknown as ReturnType<typeof useRouter>);
const { result } = renderHook(() => useClusterSearch());
expect(result.current.searchTerm).toBe('test-search');
});
it('should debounce search term', () => {
mockUseRouter.mockReturnValue({
query: { q: 'test' },
} as unknown as ReturnType<typeof useRouter>);
const { result } = renderHook(() => useClusterSearch());
expect(result.current.debouncedSearchTerm).toBe('test');
});
it('should handle empty query', () => {
mockUseRouter.mockReturnValue({
query: {},
} as unknown as ReturnType<typeof useRouter>);
const { result } = renderHook(() => useClusterSearch());
expect(result.current.searchTerm).toBe('');
expect(result.current.debouncedSearchTerm).toBe('');
});
});
+15
View File
@@ -0,0 +1,15 @@
import { useRouter } from 'next/router';
import useDebounce from 'lib/hooks/useDebounce';
import getQueryParamString from 'lib/router/getQueryParamString';
export function useClusterSearch() {
const router = useRouter();
const searchTerm = getQueryParamString(router.query.q);
const debouncedSearchTerm = useDebounce(searchTerm || '', 300);
return {
searchTerm,
debouncedSearchTerm,
};
}
+421
View File
@@ -0,0 +1,421 @@
import { renderHook } from '@testing-library/react';
import useApiQuery from 'lib/api/useApiQuery';
import { useClustersData } from './useClustersData';
jest.mock('lib/api/useApiQuery');
const mockUseApiQuery = useApiQuery as jest.MockedFunction<typeof useApiQuery>;
type MockQueryResult = ReturnType<typeof useApiQuery>;
jest.mock('lib/clusters/detectInputType', () => ({
detectInputType: jest.fn(),
}));
const mockDetectInputType = require('lib/clusters/detectInputType').detectInputType;
describe('useClustersData', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseApiQuery.mockReturnValue({
data: null,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult);
});
describe('input type detection logic', () => {
it('should default to cluster_name when no search term provided', () => {
renderHook(() => useClustersData('', 'leaderboard', 1));
expect(mockDetectInputType).not.toHaveBeenCalled();
});
it('should call detectInputType when search term exists', () => {
mockDetectInputType.mockReturnValue('address');
renderHook(() => useClustersData('0x123...', 'directory', 1));
expect(mockDetectInputType).toHaveBeenCalledWith('0x123...');
});
it('should memoize input type calculation', () => {
mockDetectInputType.mockReturnValue('cluster_name');
const { rerender } = renderHook(
({ searchTerm }) => useClustersData(searchTerm, 'directory', 1),
{ initialProps: { searchTerm: 'example.cluster' } },
);
rerender({ searchTerm: 'example.cluster' });
expect(mockDetectInputType).toHaveBeenCalledTimes(1);
});
});
describe('view mode determination', () => {
it('should show directory view when viewMode is directory', () => {
renderHook(() => useClustersData('', 'directory', 1));
expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_directory', expect.any(Object));
});
it('should show directory view when search term exists regardless of viewMode', () => {
mockDetectInputType.mockReturnValue('cluster_name');
renderHook(() => useClustersData('search', 'leaderboard', 1));
expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_directory', expect.any(Object));
});
it('should show leaderboard view when no search term and viewMode is leaderboard', () => {
renderHook(() => useClustersData('', 'leaderboard', 1));
expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_leaderboard', expect.any(Object));
});
});
describe('API query configuration', () => {
it('should configure leaderboard query with correct pagination', () => {
renderHook(() => useClustersData('', 'leaderboard', 3));
const leaderboardCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_leaderboard',
);
expect(leaderboardCall).toBeDefined();
expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"offset":100');
expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"limit":50');
expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"orderBy":"rank-asc"');
});
it('should configure directory query with search term', () => {
mockDetectInputType.mockReturnValue('cluster_name');
renderHook(() => useClustersData('example', 'directory', 2));
const directoryCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_directory',
);
expect(directoryCall).toBeDefined();
expect(directoryCall?.[1]?.queryParams?.input).toContain('"offset":50');
expect(directoryCall?.[1]?.queryParams?.input).toContain('"query":"example"');
});
it('should configure address query when input type is address', () => {
mockDetectInputType.mockReturnValue('address');
renderHook(() => useClustersData('0x123...', 'directory', 1));
const addressCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_clusters_by_address',
);
expect(addressCall).toBeDefined();
expect(addressCall?.[1]?.queryParams?.input).toContain('"address":"0x123..."');
});
it('should call cluster details query when cluster ID is available', () => {
mockDetectInputType.mockReturnValue('address');
mockUseApiQuery.mockImplementation((resource) => {
if (resource === 'clusters:get_clusters_by_address') {
return {
data: {
result: {
data: [ { clusterId: 'cluster-123' } ],
},
},
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
}
return {
data: null,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
});
renderHook(() => useClustersData('0x123...', 'directory', 1));
const clusterDetailsCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_cluster_by_id',
);
expect(clusterDetailsCall).toBeDefined();
expect(clusterDetailsCall?.[1]?.queryParams?.input).toContain('"id":"cluster-123"');
});
});
describe('dynamic ordering business logic', () => {
it('should use NAME_ASC ordering when search term exists', () => {
mockDetectInputType.mockReturnValue('cluster_name');
renderHook(() => useClustersData('search', 'directory', 1));
const directoryCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_directory',
);
expect(directoryCall?.[1]?.queryParams?.input).toContain('"orderBy":"name-asc"');
});
it('should use CREATED_AT_DESC ordering when no search term', () => {
renderHook(() => useClustersData('', 'directory', 1));
const directoryCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_directory',
);
expect(directoryCall?.[1]?.queryParams?.input).toContain('"orderBy":"createdAt-desc"');
});
it('should memoize directory order by logic', () => {
const { rerender } = renderHook(
({ searchTerm }) => useClustersData(searchTerm, 'directory', 1),
{ initialProps: { searchTerm: 'search' } },
);
jest.clearAllMocks();
rerender({ searchTerm: 'search' });
const expectedCallsPerRender = 4;
expect(mockUseApiQuery.mock.calls.length).toBe(expectedCallsPerRender);
});
});
describe('query selection logic', () => {
it('should return data from leaderboard query in leaderboard mode', () => {
const mockLeaderboardData = { result: { data: [] } };
mockUseApiQuery.mockImplementation((resource) => {
if (resource === 'clusters:get_leaderboard') {
return {
data: mockLeaderboardData,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
}
return {
data: null,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
});
const { result } = renderHook(() =>
useClustersData('', 'leaderboard', 1),
);
expect(result.current.data).toBe(mockLeaderboardData);
});
it('should return data from address query when input type is address', () => {
mockDetectInputType.mockReturnValue('address');
const mockAddressData = { result: { data: [] } };
mockUseApiQuery.mockImplementation((resource) => {
if (resource === 'clusters:get_clusters_by_address') {
return {
data: mockAddressData,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
}
return {
data: null,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
});
const { result } = renderHook(() =>
useClustersData('0x123...', 'directory', 1),
);
expect(result.current.data).toBe(mockAddressData);
});
it('should return data from directory query when input type is cluster_name', () => {
mockDetectInputType.mockReturnValue('cluster_name');
const mockDirectoryData = { result: { data: { items: [] } } };
mockUseApiQuery.mockImplementation((resource) => {
if (resource === 'clusters:get_directory') {
return {
data: mockDirectoryData,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
}
return {
data: null,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
});
const { result } = renderHook(() =>
useClustersData('example', 'directory', 1),
);
expect(result.current.data).toBe(mockDirectoryData);
});
});
describe('return value structure', () => {
it('should return correct data structure with all expected properties', () => {
const mockData = { result: { data: [] } };
const mockClusterDetails = { result: { data: { id: 'cluster-123' } } };
mockUseApiQuery.mockImplementation((resource) => {
if (resource === 'clusters:get_leaderboard') {
return {
data: mockData,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
}
if (resource === 'clusters:get_cluster_by_id') {
return {
data: mockClusterDetails,
isError: false,
isPlaceholderData: false,
isLoading: true,
} as unknown as MockQueryResult;
}
return {
data: null,
isError: false,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult;
});
const { result } = renderHook(() =>
useClustersData('', 'leaderboard', 1),
);
expect(result.current).toEqual({
data: mockData,
clusterDetails: mockClusterDetails,
isError: false,
isLoading: false,
isClusterDetailsLoading: true,
});
});
it('should handle error states correctly', () => {
mockUseApiQuery.mockReturnValue({
data: null,
isError: true,
isPlaceholderData: false,
isLoading: false,
} as unknown as MockQueryResult);
const { result } = renderHook(() =>
useClustersData('', 'leaderboard', 1),
);
expect(result.current.isError).toBe(true);
});
it('should handle loading states correctly', () => {
mockUseApiQuery.mockReturnValue({
data: null,
isError: false,
isPlaceholderData: true,
isLoading: false,
} as unknown as MockQueryResult);
const { result } = renderHook(() =>
useClustersData('', 'leaderboard', 1),
);
expect(result.current.isLoading).toBe(true);
});
});
describe('pagination calculations', () => {
it('should calculate correct offset for page 1', () => {
renderHook(() => useClustersData('', 'leaderboard', 1));
const leaderboardCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_leaderboard',
);
expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"offset":0');
});
it('should calculate correct offset for page 5', () => {
renderHook(() => useClustersData('', 'leaderboard', 5));
const leaderboardCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_leaderboard',
);
expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"offset":200');
});
it('should consistently use 50 items per page', () => {
renderHook(() => useClustersData('', 'leaderboard', 1));
const leaderboardCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_leaderboard',
);
expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"limit":50');
});
});
describe('query enabling/disabling logic', () => {
it('should disable leaderboard query when in directory view', () => {
renderHook(() => useClustersData('search', 'directory', 1));
const leaderboardCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_leaderboard',
);
expect(leaderboardCall?.[1]?.queryOptions?.enabled).toBe(false);
});
it('should disable directory query when input type is address', () => {
mockDetectInputType.mockReturnValue('address');
renderHook(() => useClustersData('0x123...', 'directory', 1));
const directoryCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_directory',
);
expect(directoryCall?.[1]?.queryOptions?.enabled).toBe(false);
});
it('should disable address query when input type is not address', () => {
mockDetectInputType.mockReturnValue('cluster_name');
renderHook(() => useClustersData('example', 'directory', 1));
const addressCall = mockUseApiQuery.mock.calls.find(call =>
call[0] === 'clusters:get_clusters_by_address',
);
expect(addressCall?.[1]?.queryOptions?.enabled).toBe(false);
});
});
});
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import type { ClustersByAddressObject } from 'types/api/clusters';
import { ClustersOrderBy } from 'types/api/clusters';
import useApiQuery from 'lib/api/useApiQuery';
import { detectInputType } from 'lib/clusters/detectInputType';
import { CLUSTER_ITEM } from 'stubs/clusters';
export function useClustersData(debouncedSearchTerm: string, viewMode: string, page: number) {
const ITEMS_PER_PAGE = 50;
const inputType = React.useMemo(() => {
if (!debouncedSearchTerm) return 'cluster_name';
return detectInputType(debouncedSearchTerm);
}, [ debouncedSearchTerm ]);
const showDirectoryView = viewMode === 'directory' || Boolean(debouncedSearchTerm);
const leaderboardQuery = useApiQuery('clusters:get_leaderboard', {
queryParams: {
input: JSON.stringify({
offset: (page - 1) * ITEMS_PER_PAGE,
limit: ITEMS_PER_PAGE,
orderBy: ClustersOrderBy.RANK_ASC,
}),
},
queryOptions: {
enabled: !showDirectoryView,
placeholderData: (previousData) => {
if (previousData) return previousData;
return {
result: {
data: Array(ITEMS_PER_PAGE).fill(CLUSTER_ITEM),
},
};
},
},
});
const getDirectoryOrderBy = React.useMemo(() => {
if (debouncedSearchTerm) {
return ClustersOrderBy.NAME_ASC;
}
return ClustersOrderBy.CREATED_AT_DESC;
}, [ debouncedSearchTerm ]);
const directoryQuery = useApiQuery('clusters:get_directory', {
queryParams: {
input: JSON.stringify({
offset: (page - 1) * ITEMS_PER_PAGE,
limit: ITEMS_PER_PAGE,
orderBy: getDirectoryOrderBy,
query: debouncedSearchTerm || '',
}),
},
queryOptions: {
enabled: showDirectoryView && inputType === 'cluster_name',
placeholderData: (previousData) => {
if (previousData) return previousData;
return {
result: {
data: {
total: 1000,
items: Array(ITEMS_PER_PAGE).fill(CLUSTER_ITEM),
},
},
};
},
},
});
const addressQuery = useApiQuery('clusters:get_clusters_by_address', {
queryParams: {
input: JSON.stringify({
address: debouncedSearchTerm,
}),
},
queryOptions: {
enabled: showDirectoryView && inputType === 'address',
placeholderData: (previousData) => {
if (previousData) return previousData;
return {
result: {
data: Array(ITEMS_PER_PAGE).fill(CLUSTER_ITEM),
},
};
},
},
});
const clusterDetailsQuery = useApiQuery('clusters:get_cluster_by_id', {
queryParams: {
input: JSON.stringify({
id: addressQuery.data?.result?.data?.[0]?.clusterId || '',
}),
},
queryOptions: {
enabled: (
showDirectoryView &&
inputType === 'address' &&
Boolean((addressQuery.data?.result?.data?.[0] as ClustersByAddressObject & { clusterId?: string })?.clusterId)
),
},
});
const { data, isError, isPlaceholderData: isLoading } = (() => {
if (!showDirectoryView) return leaderboardQuery;
if (inputType === 'address') return addressQuery;
return directoryQuery;
})();
return {
data,
clusterDetails: clusterDetailsQuery.data,
isError,
isLoading,
isClusterDetailsLoading: clusterDetailsQuery.isLoading,
};
}
+6 -6
View File
@@ -82,11 +82,11 @@ export default function useNavItems(): ReturnType {
icon: 'verified',
isActive: pathname === '/verified-contracts',
};
const ensLookup = config.features.nameService.isEnabled ? {
const nameLookup = config.features.nameService.isEnabled || config.features.clusters.isEnabled ? {
text: 'Name services lookup',
nextRoute: { pathname: '/name-domains' as const },
icon: 'ENS',
isActive: pathname === '/name-domains' || pathname === '/name-domains/[name]',
isActive: pathname === '/name-domains' || pathname === '/name-domains/[name]' || pathname === '/clusters/[name]',
} : null;
const validators = config.features.validators.isEnabled ? {
text: 'Validators',
@@ -172,7 +172,7 @@ export default function useNavItems(): ReturnType {
mudWorlds,
validators,
verifiedContracts,
ensLookup,
nameLookup,
].filter(Boolean),
];
} else if (rollupFeature.isEnabled && rollupFeature.type === 'shibarium') {
@@ -188,7 +188,7 @@ export default function useNavItems(): ReturnType {
userOps,
topAccounts,
verifiedContracts,
ensLookup,
nameLookup,
].filter(Boolean),
];
} else if (rollupFeature.isEnabled && rollupFeature.type === 'zkSync') {
@@ -204,7 +204,7 @@ export default function useNavItems(): ReturnType {
topAccounts,
validators,
verifiedContracts,
ensLookup,
nameLookup,
].filter(Boolean),
];
} else {
@@ -219,7 +219,7 @@ export default function useNavItems(): ReturnType {
topAccounts,
validators,
verifiedContracts,
ensLookup,
nameLookup,
config.features.beaconChain.isEnabled && {
text: 'Deposits',
nextRoute: { pathname: '/deposits' as const },
+1
View File
@@ -23,6 +23,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/token/[hash]/instance/[id]': 'Regular page',
'/apps': 'Root page',
'/apps/[id]': 'Regular page',
'/clusters/[name]': 'Regular page',
'/stats': 'Root page',
'/stats/[id]': 'Regular page',
'/api-docs': 'Regular page',
+1
View File
@@ -26,6 +26,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/token/[hash]/instance/[id]': '%hash%, balances and analytics on the %network_title%',
'/apps': DEFAULT_TEMPLATE,
'/apps/[id]': DEFAULT_TEMPLATE,
'/clusters/[name]': '%cluster_name% cluster | %app_name%',
'/stats': DEFAULT_TEMPLATE,
'/stats/[id]': DEFAULT_TEMPLATE,
'/api-docs': DEFAULT_TEMPLATE,
+1
View File
@@ -64,6 +64,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/interop-messages': '%network_name% interop messages',
'/operations': '%network_name% operations',
'/operation/[id]': '%network_name% operation %id%',
'/clusters/[name]': 'Clusters details for %name%',
'/cc/tx/[hash]': '%network_name% cross-chain transaction %hash% details',
// multichain routes
+1
View File
@@ -21,6 +21,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/token/[hash]/instance/[id]': 'Token Instance',
'/apps': 'DApps',
'/apps/[id]': 'DApp',
'/clusters/[name]': 'Cluster details',
'/stats': 'Stats',
'/stats/[id]': 'Stats chart',
'/api-docs': 'REST API',
+22
View File
@@ -0,0 +1,22 @@
import { useRouter } from 'next/router';
import { useCallback } from 'react';
export function useQueryParams() {
const router = useRouter();
const updateQuery = useCallback((updates: Record<string, string | undefined>) => {
const newQuery = { ...router.query };
Object.entries(updates).forEach(([ key, value ]) => {
if (value === undefined) {
delete newQuery[key];
} else {
newQuery[key] = value;
}
});
router.push({ pathname: router.pathname, query: newQuery }, undefined, { shallow: true });
}, [ router ]);
return { updateQuery };
}
+77
View File
@@ -0,0 +1,77 @@
import type { ClusterByNameResponse, ClusterByIdResponse } from 'types/api/clusters';
export const campNetworkClusterByName: ClusterByNameResponse = {
result: {
data: {
name: 'campnetwork/lol',
owner: '0x1234567890123456789012345678901234567890',
clusterId: 'clstr_1a2b3c4d5e6f7g8h9i0j',
backingWei: '5000000000000000000',
expiresAt: '2025-01-15T10:30:00Z',
createdAt: '2024-01-15T10:30:00Z',
updatedAt: '2024-01-20T14:22:00Z',
updatedBy: '0x1234567890123456789012345678901234567890',
isTestnet: false,
},
},
};
export const duckClusterByName: ClusterByNameResponse = {
result: {
data: {
name: 'duck/quack',
owner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
clusterId: 'clstr_9z8y7x6w5v4u3t2s1r0q',
backingWei: '12000000000000000000',
expiresAt: null,
createdAt: '2024-02-01T08:15:00Z',
updatedAt: '2024-02-05T16:45:00Z',
updatedBy: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
isTestnet: false,
},
},
};
export const campNetworkClusterById: ClusterByIdResponse = {
result: {
data: {
id: 'clstr_1a2b3c4d5e6f7g8h9i0j',
createdBy: '0x1234567890123456789012345678901234567890',
createdAt: '2024-01-15T10:30:00Z',
wallets: [
{
address: '0x1234567890123456789012345678901234567890',
name: 'main.campnetwork',
chainIds: [ '1', '137' ],
},
{
address: '0x0987654321098765432109876543210987654321',
name: 'treasury.campnetwork',
chainIds: [ '1' ],
},
{
address: '0x1111222233334444555566667777888899990000',
name: 'staking.campnetwork',
chainIds: [ '137', '56' ],
},
],
isTestnet: false,
},
},
};
export const testnetClusterByName: ClusterByNameResponse = {
result: {
data: {
name: 'test/cluster',
owner: '0x9876543210987654321098765432109876543210',
clusterId: 'clstr_test123456789',
backingWei: '1000000000000000000',
expiresAt: '2024-12-31T23:59:59Z',
createdAt: '2024-03-01T12:00:00Z',
updatedAt: '2024-03-01T12:00:00Z',
updatedBy: '0x9876543210987654321098765432109876543210',
isTestnet: true,
},
},
};
+77
View File
@@ -0,0 +1,77 @@
import type { ClustersDirectoryResponse, ClustersDirectoryObject } from 'types/api/clusters';
export const campNetworkCluster: ClustersDirectoryObject = {
name: 'campnetwork/lol',
isTestnet: false,
createdAt: '2024-01-15T10:30:00Z',
owner: '0x1234567890123456789012345678901234567890',
totalWeiAmount: '5000000000000000000',
updatedAt: '2024-01-20T14:22:00Z',
updatedBy: '0x1234567890123456789012345678901234567890',
chainIds: [ '1', '137', '56' ],
};
export const duckCluster: ClustersDirectoryObject = {
name: 'duck/quack',
isTestnet: false,
createdAt: '2024-02-01T08:15:00Z',
owner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
totalWeiAmount: '12000000000000000000',
updatedAt: '2024-02-05T16:45:00Z',
updatedBy: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
chainIds: [ '1', '42161' ],
};
export const testnetCluster: ClustersDirectoryObject = {
name: 'test/cluster',
isTestnet: true,
createdAt: '2024-03-01T12:00:00Z',
owner: '0x9876543210987654321098765432109876543210',
totalWeiAmount: '1000000000000000000',
updatedAt: '2024-03-01T12:00:00Z',
updatedBy: '0x9876543210987654321098765432109876543210',
chainIds: [ '11155111' ],
};
export const longNameCluster: ClustersDirectoryObject = {
name: 'this-is-a-very-long-cluster-name-that-should-test-truncation/subdomain',
isTestnet: false,
createdAt: '2024-01-10T09:20:00Z',
owner: '0x1111222233334444555566667777888899990000',
totalWeiAmount: '750000000000000000',
updatedAt: '2024-01-25T11:30:00Z',
updatedBy: '0x1111222233334444555566667777888899990000',
chainIds: [ '1' ],
};
export const clustersDirectoryMock: ClustersDirectoryResponse = {
result: {
data: {
total: 4,
items: [
campNetworkCluster,
duckCluster,
testnetCluster,
longNameCluster,
],
},
},
};
export const clustersDirectoryEmptyMock: ClustersDirectoryResponse = {
result: {
data: {
total: 0,
items: [],
},
},
};
export const clustersDirectoryLoadingMock: ClustersDirectoryResponse = {
result: {
data: {
total: 0,
items: [],
},
},
};
+74
View File
@@ -0,0 +1,74 @@
import type { ClustersLeaderboardResponse, ClustersLeaderboardObject } from 'types/api/clusters';
export const leaderboardFirst: ClustersLeaderboardObject = {
name: 'ethereum/foundation',
clusterId: 'clstr_eth_foundation_001',
isTestnet: false,
totalWeiAmount: '50000000000000000000',
totalReferralAmount: '5000000000000000000',
chainIds: [ '1', '137', '56', '42161' ],
nameCount: '127',
rank: 1,
};
export const leaderboardSecond: ClustersLeaderboardObject = {
name: 'campnetwork/lol',
clusterId: 'clstr_1a2b3c4d5e6f7g8h9i0j',
isTestnet: false,
totalWeiAmount: '25000000000000000000',
totalReferralAmount: '2500000000000000000',
chainIds: [ '1', '137', '56' ],
nameCount: '89',
rank: 2,
};
export const leaderboardThird: ClustersLeaderboardObject = {
name: 'duck/quack',
clusterId: 'clstr_9z8y7x6w5v4u3t2s1r0q',
isTestnet: false,
totalWeiAmount: '18000000000000000000',
totalReferralAmount: '1800000000000000000',
chainIds: [ '1', '42161' ],
nameCount: '56',
rank: 3,
};
export const leaderboardFourth: ClustersLeaderboardObject = {
name: 'defi/protocol',
clusterId: 'clstr_defi_protocol_xyz',
isTestnet: false,
totalWeiAmount: '12000000000000000000',
totalReferralAmount: '1200000000000000000',
chainIds: [ '1' ],
nameCount: '34',
rank: 4,
};
export const leaderboardFifth: ClustersLeaderboardObject = {
name: 'gaming/world',
clusterId: 'clstr_gaming_world_abc',
isTestnet: false,
totalWeiAmount: '8000000000000000000',
totalReferralAmount: '800000000000000000',
chainIds: [ '137', '56' ],
nameCount: '23',
rank: 5,
};
export const clustersLeaderboardMock: ClustersLeaderboardResponse = {
result: {
data: [
leaderboardFirst,
leaderboardSecond,
leaderboardThird,
leaderboardFourth,
leaderboardFifth,
],
},
};
export const clustersLeaderboardEmptyMock: ClustersLeaderboardResponse = {
result: {
data: [],
},
};
+8
View File
@@ -172,6 +172,14 @@ export const pools: Guard = (chainConfig: typeof config) => async() => {
}
};
export const clusters: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.clusters.isEnabled) {
return {
notFound: true,
};
}
};
export const zetaChainCCTX: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.zetachain.isEnabled) {
return {
+3 -1
View File
@@ -11,7 +11,9 @@ export const apiDocs = factory([ guards.apiDocs ]);
export const csvExport = factory([ guards.csvExport ]);
export const stats = factory([ guards.stats ]);
export const suave = factory([ guards.suave ]);
export const nameService = factory([ guards.nameService ]);
export const nameDomain = factory([ guards.nameService, guards.clusters ]);
export const clusters = factory([ guards.clusters ]);
export const nameDomains = factory([ [ guards.nameService, guards.clusters ] ]);
export const accounts = factory([ guards.accounts ]);
export const accountsLabelSearch = factory([ guards.accountsLabelSearch ]);
export const validators = factory([ guards.validators ]);
+1
View File
@@ -50,6 +50,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/chain/[chain-slug]/token/[hash]/instance/[id]", { "chain-slug": string; "hash": string; "id": string }>
| DynamicRoute<"/chain/[chain-slug]/tx/[hash]", { "chain-slug": string; "hash": string }>
| StaticRoute<"/chakra">
| DynamicRoute<"/clusters/[name]", { "name": string }>
| StaticRoute<"/contract-verification">
| StaticRoute<"/csv-export">
| StaticRoute<"/deposits">
+1
View File
@@ -89,6 +89,7 @@
"dayjs": "^1.11.5",
"dom-to-image": "^2.6.0",
"es-toolkit": "1.39.10",
"esbuild": "0.21.5",
"focus-visible": "^5.2.0",
"gradient-avatar": "git+https://github.com/blockscout/gradient-avatar.git",
"graphiql": "^4.1.2",
+20
View File
@@ -0,0 +1,20 @@
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps/handlers';
import PageNextJs from 'nextjs/PageNextJs';
const Cluster = dynamic(() => import('ui/pages/Cluster'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/clusters/[name]" query={ props.query }>
<Cluster/>
</PageNextJs>
);
};
export default Page;
export { clusters as getServerSideProps } from 'nextjs/getServerSideProps/main';
+1 -1
View File
@@ -17,4 +17,4 @@ const Page: NextPage<Props> = (props: Props) => {
export default Page;
export { nameService as getServerSideProps } from 'nextjs/getServerSideProps/main';
export { nameDomain as getServerSideProps } from 'nextjs/getServerSideProps/main';
+1 -1
View File
@@ -16,4 +16,4 @@ const Page: NextPage = () => {
export default Page;
export { nameService as getServerSideProps } from 'nextjs/getServerSideProps/main';
export { nameDomains as getServerSideProps } from 'nextjs/getServerSideProps/main';
+6
View File
@@ -88,6 +88,12 @@ const config: PlaywrightTestConfig = defineConfig({
{ find: '/playwright/index.ts', replacement: './playwright/index.ts' },
{ find: '/playwright/envs.js', replacement: './playwright/envs.js' },
// Fix for @libp2p/utils missing merge-options export
{ find: '@libp2p/utils/merge-options', replacement: 'merge-options' },
// Mock for @helia/verified-fetch to avoid build issues in tests
{ find: '@helia/verified-fetch', replacement: './playwright/mocks/modules/@helia/verified-fetch.js' },
],
},
define: {
+4 -1
View File
@@ -88,7 +88,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED', 'true' ],
],
nameService: [
[ 'NEXT_PUBLIC_NAME_SERVICE_API_HOST', 'https://localhost:3008' ],
[ 'NEXT_PUBLIC_NAME_SERVICE_API_HOST', 'http://localhost:3008' ],
],
rewardsService: [
[ 'NEXT_PUBLIC_REWARDS_SERVICE_API_HOST', 'http://localhost:3009' ],
@@ -117,6 +117,9 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
opSuperchain: [
[ 'NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED', 'true' ],
],
clusters: [
[ 'NEXT_PUBLIC_CLUSTERS_API_HOST', 'https://api.clusters.xyz' ],
],
zetaChain: [
[ 'NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST', 'http://localhost:3111' ],
[ 'NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', 'http://localhost:3000/zeta-config.json' ],
@@ -0,0 +1 @@
export const verifiedFetch = () => Promise.resolve(new Response());
+1
View File
@@ -38,6 +38,7 @@
| "clock-light"
| "clock"
| "close"
| "clusters"
| "coins/bitcoin"
| "collection"
| "columns"
+12
View File
@@ -0,0 +1,12 @@
import type { ClustersLeaderboardObject } from 'types/api/clusters';
export const CLUSTER_ITEM: ClustersLeaderboardObject = {
name: 'example.cluster',
clusterId: '0x1234567890123456789012345678901234567890123456789012345678901234',
isTestnet: false,
totalWeiAmount: '1000000000000000000',
totalReferralAmount: '100000000000000000',
chainIds: [ '1', '137', '42161' ],
nameCount: '10',
rank: 1,
};
@@ -1,4 +1,4 @@
export const ADDRESS_REGEXP = /^0x[a-fA-F\d]{40}$/;
import { ADDRESS_REGEXP } from 'toolkit/utils/regexp';
export const ADDRESS_LENGTH = 42;
+1
View File
@@ -279,6 +279,7 @@ const colors = {
medium: { value: '#231F20' },
reddit: { value: '#FF4500' },
celo: { value: '#FCFF52' },
clusters: { value: '#DE6061' },
// THEME COLORS
theme: defaultsDeep(config.UI.colorTheme.overrides, DEFAULT_THEME_COLORS),
+2
View File
@@ -8,3 +8,5 @@ export const HEX_REGEXP_WITH_0X = /^0x[\da-fA-F]+$/;
export const FILE_EXTENSION = /\.([\da-z]+)$/i;
export const BLOCK_HEIGHT = /^\d+$/;
export const ADDRESS_REGEXP = /^0x[a-fA-F\d]{40}$/;
+15 -1
View File
@@ -1,3 +1,17 @@
#!/bin/bash
# Install build tools required for native dependencies
apt-get update && apt-get install -y \
build-essential \
python3 \
cmake \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
yarn install --modules-folder node_modules_linux
# Set environment variables to help with native compilation
export npm_config_build_from_source=false
export npm_config_prefer_offline=true
export NODE_PATH=$(pwd)/node_modules_linux
yarn install --modules-folder node_modules_linux
+119
View File
@@ -0,0 +1,119 @@
export interface ClustersByAddressObject {
name: string;
owner: string;
totalWeiAmount: string;
createdAt: string;
updatedAt: string;
updatedBy: string;
isTestnet: boolean;
clusterId: string;
expiresAt: string | null;
}
export interface ClustersByAddressResponse {
result: {
data: Array<ClustersByAddressObject>;
};
}
export interface ClusterByNameResponse {
result: {
data: {
name: string;
owner: string;
clusterId: string;
backingWei: string;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
updatedBy: string;
isTestnet: boolean;
};
};
}
export interface ClusterByIdQueryParams {
id: string;
}
export interface ClusterByIdResponse {
result: {
data: {
id: string;
createdBy: string;
createdAt: string;
wallets: Array<{
address: string;
name: string;
chainIds: Array<string>;
}>;
isTestnet: boolean;
};
};
}
export interface ClustersLeaderboardObject {
name: string;
clusterId: string;
isTestnet: boolean;
totalWeiAmount: string;
totalReferralAmount: string;
chainIds: Array<string>;
nameCount: string;
rank: number;
}
export interface ClustersLeaderboardResponse {
result: {
data: Array<ClustersLeaderboardObject>;
};
}
export interface ClustersDirectoryObject {
name: string;
isTestnet: boolean;
createdAt: string;
owner: string;
totalWeiAmount: string;
updatedAt: string;
updatedBy: string;
chainIds: Array<string>;
}
export interface ClustersDirectoryResponse {
result: {
data: {
total: number;
items: Array<ClustersDirectoryObject>;
};
};
}
export interface ClustersByAddressQueryParams {
address: string;
}
export interface ClusterByNameQueryParams {
name: string;
}
export enum ClustersOrderBy {
RANK_ASC = 'rank-asc',
CREATED_AT_DESC = 'createdAt-desc',
NAME_ASC = 'name-asc',
}
export interface ClustersLeaderboardQueryParams {
offset?: number;
limit?: number;
orderBy?: ClustersOrderBy | string;
query?: string | null;
isExact?: boolean;
}
export interface ClustersDirectoryQueryParams {
offset?: number;
limit?: number;
orderBy?: ClustersOrderBy | string;
query?: string | null;
}
+15
View File
@@ -11,6 +11,7 @@ export const SEARCH_RESULT_TYPES = {
transaction: 'transaction',
contract: 'contract',
ens_domain: 'ens_domain',
cluster: 'cluster',
label: 'label',
user_operation: 'user_operation',
blob: 'blob',
@@ -80,6 +81,19 @@ export interface SearchResultDomain extends SearchResultAddressData {
};
}
export interface SearchResultCluster extends SearchResultAddressData {
type: 'cluster';
cluster_info: {
cluster_id: string;
name: string;
owner: string;
created_at?: string;
expires_at?: string | null;
total_wei_amount?: string;
is_testnet?: boolean;
};
}
export interface SearchResultLabel {
type: 'label';
address_hash: string;
@@ -127,6 +141,7 @@ export type SearchResultItem =
SearchResultUserOp |
SearchResultBlob |
SearchResultDomain |
SearchResultCluster |
SearchResultMetadataTag |
SearchResultTacOperation;
+114
View File
@@ -0,0 +1,114 @@
import { Grid, chakra } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { ClustersByAddressResponse } from 'types/api/clusters';
import { route } from 'nextjs-routes';
import type { ResourceError } from 'lib/api/resources';
import {
filterOwnedClusters,
getTotalRecordsDisplay,
getClusterLabel,
getClustersToShow,
getGridRows,
hasMoreClusters,
} from 'lib/clusters/clustersUtils';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tooltip } from 'toolkit/chakra/tooltip';
import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
query: UseQueryResult<ClustersByAddressResponse, ResourceError<unknown>>;
addressHash: string;
}
interface ClustersGridProps {
data: ClustersByAddressResponse['result']['data'];
}
const ClustersGrid = ({ data }: ClustersGridProps) => {
const itemsToShow = getClustersToShow(data, 10);
const numberOfRows = getGridRows(itemsToShow.length, 5);
return (
<Grid templateRows={ `repeat(${ numberOfRows }, auto)` } autoFlow="column" gap={ 4 } mt={ 2 }>
{ itemsToShow.map((cluster) => (
<ClustersEntity key={ cluster.name } clusterName={ cluster.name } fontWeight={ 600 } noCopy/>
)) }
</Grid>
);
};
const AddressClusters = ({ query, addressHash }: Props) => {
const { data, isPending, isError } = query;
if (isError) {
return null;
}
if (isPending) {
return <Skeleton loading h={ 8 } w={{ base: '50px', xl: '120px' }} borderRadius="base"/>;
}
if (!data?.result?.data || data.result.data.length === 0) {
return null;
}
const ownedClusters = filterOwnedClusters(data.result.data, addressHash);
if (ownedClusters.length === 0) {
return null;
}
const totalRecords = getTotalRecordsDisplay(ownedClusters.length);
const clusterLabel = getClusterLabel(ownedClusters.length);
const showMoreLink = hasMoreClusters(ownedClusters.length, 10);
return (
<PopoverRoot>
<Tooltip content="List of clusters registered to this address" disableOnMobile>
<div>
<PopoverTrigger>
<Button
size="sm"
variant="dropdown"
aria-label="Address clusters"
fontWeight={ 500 }
flexShrink={ 0 }
columnGap={ 1 }
role="group"
>
<IconSvg name="clusters" boxSize={ 5 } fill="currentColor"/>
<chakra.span hideBelow="xl">{ totalRecords } { clusterLabel }</chakra.span>
<chakra.span hideFrom="xl">{ totalRecords }</chakra.span>
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent w={{ lg: '500px' }}>
<PopoverBody textStyle="sm" display="flex" flexDir="column" rowGap={ 5 } alignItems="flex-start">
<div>
<chakra.span color="text.secondary" textStyle="xs">Attached to this address</chakra.span>
<ClustersGrid data={ ownedClusters }/>
</div>
{ showMoreLink && (
<Link
href={ route({ pathname: '/name-domains', query: { q: addressHash, tab: 'directories' } }) }
>
<span>More results</span>
<chakra.span color="text.secondary"> ({ totalRecords })</chakra.span>
</Link>
) }
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
export default AddressClusters;
+106
View File
@@ -0,0 +1,106 @@
import React from 'react';
import type { ClusterByNameResponse } from 'types/api/clusters';
import { isEvmAddress } from 'lib/address/isEvmAddress';
import { currencyUnits } from 'lib/units';
import { Skeleton } from 'toolkit/chakra/skeleton';
import CurrencyValue from 'ui/shared/CurrencyValue';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity';
interface Props {
clusterData?: ClusterByNameResponse['result']['data'];
clusterName: string;
isLoading: boolean;
}
const ClusterDetails = ({ clusterData, clusterName, isLoading }: Props) => {
if (!clusterData && !isLoading) {
throw new Error('Cluster not found', { cause: { status: 404 } });
}
const ownerIsEvm = clusterData?.owner ? isEvmAddress(clusterData.owner) : false;
const addressType = ownerIsEvm ? 'EVM' : 'NON-EVM';
return (
<DetailedInfo.Container>
<DetailedInfo.ItemLabel
hint="The unique cluster name"
isLoading={ isLoading }
>
Cluster name
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<ClustersEntity
clusterName={ clusterName }
isLoading={ isLoading }
noLink
fontWeight={ 500 }
/>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint="The address attached to this cluster name"
isLoading={ isLoading }
>
Owner address
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<AddressEntity
address={{ hash: clusterData?.owner || '' }}
isLoading={ isLoading }
fontWeight={ 500 }
noLink={ !ownerIsEvm }
/>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint="The network type of the address attached to this cluster name"
isLoading={ isLoading }
>
Type
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<Skeleton loading={ isLoading }>
{ addressType }
</Skeleton>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint={ `The amount of ${ currencyUnits.ether } backing this cluster name` }
isLoading={ isLoading }
>
Backing
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<CurrencyValue
value={ clusterData?.backingWei || '0' }
currency={ currencyUnits.ether }
isLoading={ isLoading }
/>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint="When this cluster name was created"
isLoading={ isLoading }
>
Created
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
{ clusterData?.createdAt ? (
<DetailedInfoTimestamp
timestamp={ clusterData.createdAt }
isLoading={ isLoading }
/>
) : (
<Skeleton loading={ isLoading }>N/A</Skeleton>
) }
</DetailedInfo.ItemValue>
</DetailedInfo.Container>
);
};
export default ClusterDetails;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+158
View File
@@ -0,0 +1,158 @@
import { Box, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import { detectInputType } from 'lib/clusters/detectInputType';
import {
shouldShowDirectoryView,
transformLeaderboardData,
transformFullDirectoryData,
applyDirectoryPagination,
calculateHasNextPage,
getCurrentDataLength,
} from 'lib/clusters/pageUtils';
import type { ViewMode } from 'lib/clusters/pageUtils';
import { useClusterPagination } from 'lib/clusters/useClusterPagination';
import { useClustersData } from 'lib/clusters/useClustersData';
import { useClusterSearch } from 'lib/clusters/useClusterSearch';
import getQueryParamString from 'lib/router/getQueryParamString';
import { useQueryParams } from 'lib/router/useQueryParams';
import { apos } from 'toolkit/utils/htmlEntities';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import ClustersActionBar from './ClustersActionBar';
import ClustersDirectoryListItem from './ClustersDirectoryListItem';
import ClustersDirectoryTable from './ClustersDirectoryTable';
import ClustersLeaderboardListItem from './ClustersLeaderboardListItem';
import ClustersLeaderboardTable from './ClustersLeaderboardTable';
const Clusters = () => {
const router = useRouter();
const { updateQuery } = useQueryParams();
const { searchTerm, debouncedSearchTerm } = useClusterSearch();
const viewMode = (getQueryParamString(router.query.view) as ViewMode) || 'directory';
const page = parseInt(getQueryParamString(router.query.page) || '1', 10);
const inputType = React.useMemo(() => {
if (!debouncedSearchTerm) return 'cluster_name';
return detectInputType(debouncedSearchTerm);
}, [ debouncedSearchTerm ]);
const { data, clusterDetails, isError, isLoading, isClusterDetailsLoading } = useClustersData(debouncedSearchTerm, viewMode, page);
const showDirectoryView = shouldShowDirectoryView(viewMode, Boolean(debouncedSearchTerm));
const leaderboardData = transformLeaderboardData(data, showDirectoryView);
const fullDirectoryData = transformFullDirectoryData(data, clusterDetails, inputType, showDirectoryView);
const limit = 50;
const directoryData = applyDirectoryPagination(fullDirectoryData, inputType, page, limit);
const currentDataLength = getCurrentDataLength(showDirectoryView, directoryData.length, leaderboardData.length);
const hasNextPage = calculateHasNextPage(
data,
leaderboardData.length,
fullDirectoryData.length,
showDirectoryView,
inputType,
page,
Boolean(debouncedSearchTerm),
limit,
);
const { pagination } = useClusterPagination(hasNextPage, isLoading);
const handleViewModeChange = useCallback((newViewMode: ViewMode) => {
updateQuery({
view: newViewMode === 'directory' ? undefined : newViewMode,
page: undefined,
});
}, [ updateQuery ]);
const handleSearchChange = useCallback((value: string) => {
updateQuery({
q: value || undefined,
page: undefined,
});
}, [ updateQuery ]);
const hasActiveFilters = Boolean(debouncedSearchTerm);
const content = (
<>
<Box hideFrom="lg">
{ showDirectoryView ? (
directoryData.map((item, index) => (
<ClustersDirectoryListItem
key={ `${ item.name }-${ index }${ isLoading ? '-loading' : '' }` }
item={ item }
isLoading={ isLoading }
isClusterDetailsLoading={ isClusterDetailsLoading && inputType === 'address' }
/>
))
) : (
leaderboardData.map((item, index) => (
<ClustersLeaderboardListItem
key={ `${ item.name }-${ index }${ isLoading ? '-loading' : '' }` }
item={ item }
isLoading={ isLoading }
/>
))
) }
</Box>
<Box hideBelow="lg">
{ showDirectoryView ? (
<ClustersDirectoryTable
data={ directoryData }
isLoading={ isLoading }
isClusterDetailsLoading={ isClusterDetailsLoading && inputType === 'address' }
top={ ACTION_BAR_HEIGHT_DESKTOP }
/>
) : (
<ClustersLeaderboardTable
data={ leaderboardData }
isLoading={ isLoading }
top={ ACTION_BAR_HEIGHT_DESKTOP }
/>
) }
</Box>
</>
);
const actionBar = (
<ClustersActionBar
isLoading={ isLoading }
searchTerm={ searchTerm }
onSearchChange={ handleSearchChange }
viewMode={ viewMode }
onViewModeChange={ handleViewModeChange }
pagination={ pagination }
/>
);
return (
<>
<Text mb={ 6 } textStyle={{ base: 'sm', lg: 'md' }}>
Clusters is a cross-chain name service for managing addresses on multiple blockchains using a universal naming directory.
</Text>
<DataListDisplay
isError={ isError }
itemsNum={ currentDataLength }
filterProps={{
emptyFilteredText: `Couldn${ apos }t find clusters that match your query.`,
hasActiveFilters,
}}
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
</>
);
};
export default Clusters;
@@ -0,0 +1,90 @@
import { Flex, VStack, Box } from '@chakra-ui/react';
import React from 'react';
import type { PaginationParams } from 'ui/shared/pagination/types';
import {
getSearchPlaceholder,
shouldShowActionBar,
} from 'lib/clusters/actionBarUtils';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import { Button, ButtonGroupRadio } from 'toolkit/chakra/button';
import { FilterInput } from 'toolkit/components/filters/FilterInput';
import ActionBar from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/pagination/Pagination';
type ViewMode = 'leaderboard' | 'directory';
interface Props {
pagination: PaginationParams;
searchTerm: string | undefined;
onSearchChange: (value: string) => void;
viewMode: ViewMode;
onViewModeChange: (viewMode: ViewMode) => void;
isLoading: boolean;
}
const ClustersActionBar = ({
searchTerm,
onSearchChange,
viewMode,
onViewModeChange,
isLoading,
pagination,
}: Props) => {
const isInitialLoading = useIsInitialLoading(isLoading);
const handleViewModeChange = React.useCallback((value: string) => {
onViewModeChange(value as ViewMode);
}, [ onViewModeChange ]);
const placeholder = getSearchPlaceholder();
const showActionBarOnMobile = shouldShowActionBar(pagination.isVisible, false);
const showActionBarOnDesktop = shouldShowActionBar(pagination.isVisible, true);
const filters = (
<Flex columnGap={ 3 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }}>
<ButtonGroupRadio
defaultValue={ viewMode }
onChange={ handleViewModeChange }
w={{ lg: 'fit-content' }}
loading={ isInitialLoading }
>
<Button value="directory" size="sm" px={ 3 }>
Directory
</Button>
<Button value="leaderboard" size="sm" px={ 3 }>
Leaderboard
</Button>
</ButtonGroupRadio>
<FilterInput
initialValue={ searchTerm }
onChange={ onSearchChange }
placeholder={ placeholder }
w={{ base: '100%', lg: '360px' }}
minW={{ base: 'auto', lg: '250px' }}
size="sm"
loading={ isInitialLoading }
/>
</Flex>
);
return (
<>
<VStack gap={ 3 } mb={ 6 } hideFrom="lg" align="stretch">
{ filters }
</VStack>
<ActionBar
mt={ -6 }
display={{ base: showActionBarOnMobile ? 'flex' : 'none', lg: showActionBarOnDesktop ? 'flex' : 'none' }}
>
<Box hideBelow="lg">
{ filters }
</Box>
<Pagination { ...pagination } ml="auto"/>
</ActionBar>
</>
);
};
export default React.memo(ClustersActionBar);
@@ -0,0 +1,65 @@
import React from 'react';
import type { ClustersDirectoryObject } from 'types/api/clusters';
import { isEvmAddress } from 'lib/address/isEvmAddress';
import dayjs from 'lib/date/dayjs';
import { Skeleton } from 'toolkit/chakra/skeleton';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
interface Props {
item: ClustersDirectoryObject;
isLoading?: boolean;
isClusterDetailsLoading?: boolean;
}
const ClustersDirectoryListItem = ({ item, isLoading, isClusterDetailsLoading }: Props) => {
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Cluster name
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<ClustersEntity clusterName={ item.name } isLoading={ isLoading } fontWeight={ 500 }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Address
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ item.owner && (
<AddressEntity
address={{ hash: item.owner }}
isLoading={ isLoading }
fontWeight={ 500 }
noLink={ !isEvmAddress(item.owner) }
/>
) }
{ !item.owner && <Skeleton loading={ isLoading }>—</Skeleton> }
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Joined
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton loading={ isLoading }>
<div>{ dayjs(item.createdAt).format('lll') }</div>
<div> { dayjs(item.createdAt).fromNow() }</div>
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Active chains
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton loading={ isLoading || isClusterDetailsLoading }>
{ (item.chainIds?.length || 1) } { (item.chainIds?.length || 1) === 1 ? 'chain' : 'chains' }
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default React.memo(ClustersDirectoryListItem);
@@ -0,0 +1,45 @@
import React from 'react';
import type { ClustersDirectoryObject } from 'types/api/clusters';
import { TableBody, TableHeaderSticky, TableRow, TableColumnHeader, TableRoot } from 'toolkit/chakra/table';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import ClustersDirectoryTableItem from './ClustersDirectoryTableItem';
interface Props {
data: Array<ClustersDirectoryObject>;
isLoading?: boolean;
top?: number;
isClusterDetailsLoading?: boolean;
}
const ClustersDirectoryTable = ({ data, isLoading, top, isClusterDetailsLoading }: Props) => {
return (
<TableRoot>
<TableHeaderSticky top={ top }>
<TableRow>
<TableColumnHeader width="40%">Cluster name</TableColumnHeader>
<TableColumnHeader width="40%">Address</TableColumnHeader>
<TableColumnHeader width="180px">
Joined
<TimeFormatToggle/>
</TableColumnHeader>
<TableColumnHeader width="20%">Active chains</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ data.map((item, index) => (
<ClustersDirectoryTableItem
key={ `${ item.name }-${ index }${ isLoading ? '-loading' : '' }` }
item={ item }
isLoading={ isLoading }
isClusterDetailsLoading={ isClusterDetailsLoading }
/>
)) }
</TableBody>
</TableRoot>
);
};
export default React.memo(ClustersDirectoryTable);
@@ -0,0 +1,51 @@
import React from 'react';
import type { ClustersDirectoryObject } from 'types/api/clusters';
import { isEvmAddress } from 'lib/address/isEvmAddress';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
interface Props {
item: ClustersDirectoryObject;
isLoading?: boolean;
isClusterDetailsLoading?: boolean;
}
const ClustersDirectoryTableItem = ({ item, isLoading, isClusterDetailsLoading }: Props) => {
return (
<TableRow>
<TableCell verticalAlign="middle">
<ClustersEntity clusterName={ item.name } isLoading={ isLoading } fontWeight={ 600 }/>
</TableCell>
<TableCell verticalAlign="middle">
{ item.owner && (
<AddressEntity
address={{ hash: item.owner }}
isLoading={ isLoading }
fontWeight={ 500 }
noLink={ !isEvmAddress(item.owner) }
/>
) }
{ !item.owner && <Skeleton loading={ isLoading }>—</Skeleton> }
</TableCell>
<TableCell verticalAlign="middle">
<TimeWithTooltip
timestamp={ item.createdAt }
isLoading={ isLoading }
enableIncrement={ true }
/>
</TableCell>
<TableCell verticalAlign="middle">
<Skeleton loading={ isLoading || isClusterDetailsLoading }>
{ (item.chainIds?.length || 1) } { (item.chainIds?.length || 1) === 1 ? 'chain' : 'chains' }
</Skeleton>
</TableCell>
</TableRow>
);
};
export default React.memo(ClustersDirectoryTableItem);
@@ -0,0 +1,63 @@
import React from 'react';
import type { ClustersLeaderboardObject } from 'types/api/clusters';
import { Skeleton } from 'toolkit/chakra/skeleton';
import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
interface Props {
item: ClustersLeaderboardObject;
isLoading?: boolean;
}
const ClustersLeaderboardListItem = ({ item, isLoading }: Props) => {
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Rank
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton loading={ isLoading }>
#{ item.rank }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Cluster name
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<ClustersEntity clusterName={ item.name } isLoading={ isLoading } fontWeight={ 500 }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Names
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton loading={ isLoading }>
{ item.nameCount }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Backing
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton loading={ isLoading }>
{ (parseFloat(item.totalWeiAmount) / 1e18).toFixed(2) } ETH
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>
Network presence
</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton loading={ isLoading }>
{ item.chainIds.length } { item.chainIds.length === 1 ? 'chain' : 'chains' }
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default React.memo(ClustersLeaderboardListItem);
@@ -0,0 +1,40 @@
import React from 'react';
import type { ClustersLeaderboardObject } from 'types/api/clusters';
import { TableBody, TableHeaderSticky, TableRow, TableColumnHeader, TableRoot } from 'toolkit/chakra/table';
import ClustersLeaderboardTableItem from './ClustersLeaderboardTableItem';
interface Props {
data: Array<ClustersLeaderboardObject>;
isLoading?: boolean;
top?: number;
}
const ClustersLeaderboardTable = ({ data, isLoading, top }: Props) => {
return (
<TableRoot>
<TableHeaderSticky top={ top }>
<TableRow>
<TableColumnHeader width="5%">Rank</TableColumnHeader>
<TableColumnHeader width="40%">Cluster name</TableColumnHeader>
<TableColumnHeader width="10%">Names</TableColumnHeader>
<TableColumnHeader width="10%">Total backing</TableColumnHeader>
<TableColumnHeader width="10%">Active chains</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ data.map((item, index) => (
<ClustersLeaderboardTableItem
key={ `${ item.name }-${ index }${ isLoading ? '-loading' : '' }` }
item={ item }
isLoading={ isLoading }
/>
)) }
</TableBody>
</TableRoot>
);
};
export default React.memo(ClustersLeaderboardTable);
@@ -0,0 +1,44 @@
import React from 'react';
import type { ClustersLeaderboardObject } from 'types/api/clusters';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity';
interface Props {
item: ClustersLeaderboardObject;
isLoading?: boolean;
}
const ClustersLeaderboardTableItem = ({ item, isLoading }: Props) => {
return (
<TableRow>
<TableCell verticalAlign="middle">
<Skeleton loading={ isLoading }>
#{ item.rank }
</Skeleton>
</TableCell>
<TableCell verticalAlign="middle">
<ClustersEntity clusterName={ item.name } isLoading={ isLoading } fontWeight={ 600 }/>
</TableCell>
<TableCell verticalAlign="middle">
<Skeleton loading={ isLoading }>
{ item.nameCount }
</Skeleton>
</TableCell>
<TableCell verticalAlign="middle">
<Skeleton loading={ isLoading }>
{ (parseFloat(item.totalWeiAmount) / 1e18).toFixed(2) } ETH
</Skeleton>
</TableCell>
<TableCell verticalAlign="middle">
<Skeleton loading={ isLoading }>
{ item.chainIds.length } { item.chainIds.length === 1 ? 'chain' : 'chains' }
</Skeleton>
</TableCell>
</TableRow>
);
};
export default React.memo(ClustersLeaderboardTableItem);
@@ -0,0 +1,247 @@
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { EnsDomainLookupFiltersOptions, EnsLookupSorting } from 'types/api/ens';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ENS_DOMAIN } from 'stubs/ENS';
import { generateListStub } from 'stubs/utils';
import { apos } from 'toolkit/utils/htmlEntities';
import { ADDRESS_REGEXP } from 'toolkit/utils/regexp';
import DataListDisplay from 'ui/shared/DataListDisplay';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import NameDomainsActionBar from './NameDomainsActionBar';
import NameDomainsListItem from './NameDomainsListItem';
import NameDomainsTable from './NameDomainsTable';
import type { Sort, SortField } from './utils';
import { SORT_OPTIONS, getNextSortValue } from './utils';
const NameDomainsContent = () => {
const router = useRouter();
const q = getQueryParamString(router.query.name) || getQueryParamString(router.query.address);
const ownedBy = getQueryParamString(router.query.owned_by);
const resolvedTo = getQueryParamString(router.query.resolved_to);
const onlyActive = getQueryParamString(router.query.only_active);
const protocols = Array.isArray(router.query.protocols) ? router.query.protocols : (router.query.protocols ?? '').split(',').filter(Boolean);
const initialFilters: EnsDomainLookupFiltersOptions = [
ownedBy === 'true' ? 'owned_by' as const : undefined,
resolvedTo === 'true' ? 'resolved_to' as const : undefined,
onlyActive === 'false' ? 'with_inactive' as const : undefined,
].filter(Boolean);
const initialSort = getSortValueFromQuery<Sort>(router.query, SORT_OPTIONS);
const [ searchTerm, setSearchTerm ] = React.useState<string>(q || '');
const [ filterValue, setFilterValue ] = React.useState<EnsDomainLookupFiltersOptions>(initialFilters);
const [ sort, setSort ] = React.useState<Sort>(initialSort ?? 'default');
const [ protocolsFilter, setProtocolsFilter ] = React.useState<Array<string>>(protocols);
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const isAddressSearch = React.useMemo(() => ADDRESS_REGEXP.test(debouncedSearchTerm), [ debouncedSearchTerm ]);
const sortParams = getSortParamsFromValue<Sort, EnsLookupSorting['sort'], EnsLookupSorting['order']>(sort);
const addressesLookupQuery = useQueryWithPages({
resourceName: 'bens:addresses_lookup',
pathParams: { chainId: config.chain.id },
filters: {
address: debouncedSearchTerm,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined,
},
sorting: sortParams,
options: {
enabled: isAddressSearch,
placeholderData: generateListStub<'bens:addresses_lookup'>(ENS_DOMAIN, 50, { next_page_params: undefined }),
},
});
const domainsLookupQuery = useQueryWithPages({
resourceName: 'bens:domains_lookup',
pathParams: { chainId: config.chain.id },
filters: {
name: debouncedSearchTerm,
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined,
},
sorting: sortParams,
options: {
enabled: !isAddressSearch,
placeholderData: generateListStub<'bens:domains_lookup'>(ENS_DOMAIN, 50, { next_page_params: undefined }),
},
});
const protocolsQuery = useApiQuery('bens:domain_protocols', {
pathParams: { chainId: config.chain.id },
});
const query = isAddressSearch ? addressesLookupQuery : domainsLookupQuery;
const { data, isError, isPlaceholderData: isLoading, onFilterChange, onSortingChange } = query;
React.useEffect(() => {
const hasInactiveFilter = filterValue.some((value) => value === 'with_inactive');
if (isAddressSearch) {
setFilterValue([ 'owned_by' as const, 'resolved_to' as const, hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean));
onFilterChange<'bens:addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: true,
owned_by: true,
only_active: !hasInactiveFilter,
protocols: protocolsFilter,
});
} else {
setFilterValue([ hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean));
onFilterChange<'bens:domains_lookup'>({
name: debouncedSearchTerm,
only_active: !hasInactiveFilter,
protocols: protocolsFilter,
});
}
// should run only the type of search changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isAddressSearch ]);
const handleSortToggle = React.useCallback((field: SortField) => {
if (isLoading) {
return;
}
if (field) {
setSort((prevValue) => {
const nextSortValue = getNextSortValue(field)(prevValue);
onSortingChange(getSortParamsFromValue(nextSortValue));
return nextSortValue;
});
}
}, [ isLoading, onSortingChange ]);
const handleSearchTermChange = React.useCallback((value: string) => {
setSearchTerm(value);
const isAddressSearch = ADDRESS_REGEXP.test(value);
if (isAddressSearch) {
onFilterChange<'bens:addresses_lookup'>({
address: value,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter,
});
} else {
onFilterChange<'bens:domains_lookup'>({
name: value,
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter,
});
}
}, [ onFilterChange, filterValue, protocolsFilter ]);
const handleFilterValueChange = React.useCallback((value: EnsDomainLookupFiltersOptions) => {
setFilterValue(value);
const isAddressSearch = ADDRESS_REGEXP.test(debouncedSearchTerm);
if (isAddressSearch) {
onFilterChange<'bens:addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: value.includes('resolved_to'),
owned_by: value.includes('owned_by'),
only_active: !value.includes('with_inactive'),
protocols: protocolsFilter,
});
} else {
onFilterChange<'bens:domains_lookup'>({
name: debouncedSearchTerm,
only_active: !value.includes('with_inactive'),
protocols: protocolsFilter,
});
}
}, [ debouncedSearchTerm, onFilterChange, protocolsFilter ]);
const handleProtocolsFilterChange = React.useCallback((nextValue: Array<string>) => {
setProtocolsFilter(nextValue);
const isAddressSearch = ADDRESS_REGEXP.test(debouncedSearchTerm);
if (isAddressSearch) {
onFilterChange<'bens:addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
protocols: nextValue,
});
} else {
onFilterChange<'bens:domains_lookup'>({
name: debouncedSearchTerm,
only_active: !filterValue.includes('with_inactive'),
protocols: nextValue,
});
}
}, [ debouncedSearchTerm, filterValue, onFilterChange ]);
const hasActiveFilters = Boolean(debouncedSearchTerm) || filterValue.length > 0 ||
(protocolsQuery.data && protocolsQuery.data.items.length > 1 ? protocolsFilter.length > 0 : false);
const content = (
<>
<Box hideFrom="lg">
{ data?.items.map((item, index) => (
<NameDomainsListItem
key={ item.id + (isLoading ? index : '') }
{ ...item }
isLoading={ isLoading }
/>
)) }
</Box>
<Box hideBelow="lg">
<NameDomainsTable
data={ data }
isLoading={ isLoading }
sort={ sort }
onSortToggle={ handleSortToggle }
/>
</Box>
</>
);
const actionBar = (
<NameDomainsActionBar
isLoading={ isLoading }
searchTerm={ searchTerm }
onSearchChange={ handleSearchTermChange }
filterValue={ filterValue }
onFilterValueChange={ handleFilterValueChange }
protocolsData={ protocolsQuery.data?.items }
protocolsFilterValue={ protocolsFilter }
onProtocolsFilterChange={ handleProtocolsFilterChange }
sort={ sort }
onSortChange={ setSort }
isAddressSearch={ isAddressSearch }
pagination={ query.pagination }
/>
);
return (
<DataListDisplay
isError={ isError }
itemsNum={ data?.items.length }
emptyText="There are no name domains."
filterProps={{
emptyFilteredText: `Couldn${ apos }t find name domains that match your filter query.`,
hasActiveFilters,
}}
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
);
};
export default NameDomainsContent;
+6
View File
@@ -10,6 +10,7 @@ import getCheckedSummedAddress from 'lib/address/getCheckedSummedAddress';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import useAddressMetadataInitUpdate from 'lib/address/useAddressMetadataInitUpdate';
import useApiQuery from 'lib/api/useApiQuery';
import { useAddressClusters } from 'lib/clusters/useAddressClusters';
import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText';
@@ -38,6 +39,7 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import AddressUserOps from 'ui/address/AddressUserOps';
import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import AddressClusters from 'ui/address/clusters/AddressClusters';
import { CONTRACT_TAB_IDS } from 'ui/address/contract/utils';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressMetadataAlert from 'ui/address/details/AddressMetadataAlert';
@@ -131,6 +133,8 @@ const AddressPageContent = () => {
addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) :
undefined;
const addressClustersQuery = useAddressClusters(hash, areQueriesEnabled);
const addressType = addressQuery.data?.is_contract && addressQuery.data?.proxy_type !== 'eip7702' ? 'contract' : 'eoa';
const address3rdPartyWidgets = useAddress3rdPartyWidgets(addressType, addressQuery.isPlaceholderData, areQueriesEnabled);
@@ -438,6 +442,8 @@ const AddressPageContent = () => {
<SolidityscanReport hash={ hash }/> }
{ !isLoading && addressEnsDomainsQuery.data && config.features.nameService.isEnabled &&
<AddressEnsDomains query={ addressEnsDomainsQuery } addressHash={ hash } mainDomainName={ addressQuery.data?.ens_domain_name }/> }
{ !isLoading && addressClustersQuery.data && config.features.clusters.isEnabled &&
<AddressClusters query={ addressClustersQuery } addressHash={ hash }/> }
<NetworkExplorers type="address" pathParam={ hash }/>
</Flex>
);
+78
View File
@@ -0,0 +1,78 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import { campNetworkClusterByName, testnetClusterByName } from 'mocks/clusters/cluster';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import Cluster from './Cluster';
const CDN_URL = 'https://localhost:3000/cdn';
test.beforeEach(async({ mockEnvs, mockTextAd }) => {
await mockEnvs([
...ENVS_MAP.clusters,
[ 'NEXT_PUBLIC_CLUSTERS_CDN_URL', CDN_URL ],
]);
await mockTextAd();
});
test.describe('Cluster Details Page', () => {
test('mainnet cluster details +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => {
await mockAssetResponse(
`${ CDN_URL }/profile-image/campnetwork/lol`,
'./playwright/mocks/image_s.jpg',
);
await mockApiResponse('clusters:get_cluster_by_name', campNetworkClusterByName, {
queryParams: {
input: JSON.stringify({ name: 'campnetwork/lol' }),
},
});
const component = await render(
<div>
<Box h={{ base: '134px', lg: 6 }}/>
<Cluster/>
</div>,
{
hooksConfig: {
router: {
query: { name: 'campnetwork/lol' },
isReady: true,
},
},
},
);
await expect(component).toHaveScreenshot();
});
test('testnet cluster details +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => {
await mockAssetResponse(
`${ CDN_URL }/profile-image/test/cluster`,
'./playwright/mocks/image_s.jpg',
);
await mockApiResponse('clusters:get_cluster_by_name', testnetClusterByName, {
queryParams: {
input: JSON.stringify({ name: 'test/cluster' }),
},
});
const component = await render(
<div>
<Box h={{ base: '134px', lg: 6 }}/>
<Cluster/>
</div>,
{
hooksConfig: {
router: {
query: { name: 'test/cluster' },
isReady: true,
},
},
},
);
await expect(component).toHaveScreenshot();
});
});
+38
View File
@@ -0,0 +1,38 @@
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ClusterDetails from 'ui/cluster/ClusterDetails';
import TextAd from 'ui/shared/ad/TextAd';
import PageTitle from 'ui/shared/Page/PageTitle';
const Cluster = () => {
const router = useRouter();
const encodedClusterName = getQueryParamString(router.query.name);
const clusterName = decodeURIComponent(encodedClusterName || '');
const clusterQuery = useApiQuery('clusters:get_cluster_by_name', {
queryParams: {
input: JSON.stringify({ name: clusterName }),
},
});
const clusterData = clusterQuery.data?.result?.data;
const isLoading = clusterQuery.isLoading;
return (
<>
<TextAd mb={ 6 }/>
<PageTitle title="Cluster details"/>
<ClusterDetails
clusterData={ clusterData }
clusterName={ clusterName }
isLoading={ isLoading }
/>
</>
);
};
export default Cluster;
+130 -31
View File
@@ -1,46 +1,145 @@
import React from 'react';
import config from 'configs/app';
import { clustersDirectoryMock } from 'mocks/clusters/directory';
import { clustersLeaderboardMock } from 'mocks/clusters/leaderboard';
import * as ensDomainMock from 'mocks/ens/domain';
import { test, expect } from 'playwright/lib';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect, devices } from 'playwright/lib';
import NameDomains from './NameDomains';
test.beforeEach(async({ mockApiResponse, mockAssetResponse, mockTextAd }) => {
await mockTextAd();
await mockAssetResponse(ensDomainMock.protocolA.icon_url as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(ensDomainMock.protocolB.icon_url as string, './playwright/mocks/image_md.jpg');
await mockApiResponse('bens:domains_lookup', {
items: [
ensDomainMock.ensDomainA,
ensDomainMock.ensDomainB,
ensDomainMock.ensDomainC,
ensDomainMock.ensDomainD,
],
next_page_params: {
page_token: '<token>',
page_size: 50,
test.describe('domains', () => {
const hooksConfig = {
router: {
query: { tab: 'domains' },
},
}, {
pathParams: { chainId: config.chain.id },
queryParams: { only_active: true },
};
test.beforeEach(async({ mockApiResponse, mockAssetResponse, mockTextAd, mockEnvs }) => {
await mockTextAd();
await mockEnvs([
...ENVS_MAP.clusters,
...ENVS_MAP.nameService,
]);
await mockAssetResponse(ensDomainMock.protocolA.icon_url as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(ensDomainMock.protocolB.icon_url as string, './playwright/mocks/image_md.jpg');
await mockApiResponse('bens:domains_lookup', {
items: [
ensDomainMock.ensDomainA,
ensDomainMock.ensDomainB,
ensDomainMock.ensDomainC,
ensDomainMock.ensDomainD,
],
next_page_params: {
page_token: '<token>',
page_size: 50,
},
}, {
pathParams: { chainId: config.chain.id },
queryParams: { only_active: true },
});
await mockApiResponse('bens:domain_protocols', {
items: [ ensDomainMock.protocolA, ensDomainMock.protocolB ],
}, {
pathParams: { chainId: config.chain.id },
});
});
await mockApiResponse('bens:domain_protocols', {
items: [ ensDomainMock.protocolA, ensDomainMock.protocolB ],
}, {
pathParams: { chainId: config.chain.id },
test('default view', async({ render }) => {
test.slow();
const component = await render(<NameDomains/>, { hooksConfig });
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('default view', async({ render }) => {
const component = await render(<NameDomains/>, { hooksConfig });
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
});
test('filters', async({ render, page }) => {
const component = await render(<NameDomains/>, { hooksConfig });
await component.getByRole('button', { name: 'Filter' }).click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 500 } });
});
});
test('default view +@mobile', async({ render }) => {
test.slow();
const component = await render(<NameDomains/>);
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
test.describe('directories', () => {
test('filters', async({ render, page }) => {
const component = await render(<NameDomains/>);
const CDN_URL = 'https://localhost:3000/cdn';
await component.getByRole('button', { name: 'Filter' }).click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 500 } });
const hooksConfig = {
directories: {
router: {
query: { tab: 'directories', view: 'directory' },
isReady: true,
},
},
leaderboard: {
router: {
query: { tab: 'directories', view: 'leaderboard' },
isReady: true,
},
},
};
test.beforeEach(async({ mockEnvs, mockTextAd, mockApiResponse, mockAssetResponse }) => {
await mockEnvs([
...ENVS_MAP.clusters,
[ 'NEXT_PUBLIC_CLUSTERS_CDN_URL', CDN_URL ],
]);
await mockTextAd();
await mockAssetResponse(`${ CDN_URL }/profile-image/campnetwork/lol`, './playwright/mocks/image_s.jpg');
await mockAssetResponse(`${ CDN_URL }/profile-image/duck/quack`, './playwright/mocks/image_s.jpg');
await mockAssetResponse(`${ CDN_URL }/profile-image/test/cluster`, './playwright/mocks/image_s.jpg');
await mockApiResponse('clusters:get_directory', clustersDirectoryMock, {
queryParams: {
input: JSON.stringify({
offset: 0,
limit: 50,
orderBy: 'createdAt-desc',
query: '',
}),
},
});
await mockApiResponse('clusters:get_leaderboard', clustersLeaderboardMock, {
queryParams: {
input: JSON.stringify({
offset: 0,
limit: 50,
orderBy: 'rank-asc',
}),
},
});
});
test('directory view', async({ render }) => {
const component = await render(<NameDomains/>, { hooksConfig: hooksConfig.directories });
await expect(component).toHaveScreenshot();
});
test('leaderboard view', async({ render }) => {
const component = await render(<NameDomains/>, { hooksConfig: hooksConfig.leaderboard });
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('directory view', async({ render }) => {
const component = await render(<NameDomains/>, { hooksConfig: hooksConfig.directories });
await expect(component).toHaveScreenshot();
});
test('leaderboard view', async({ render }) => {
const component = await render(<NameDomains/>, { hooksConfig: hooksConfig.leaderboard });
await expect(component).toHaveScreenshot();
});
});
});
+8 -233
View File
@@ -1,232 +1,18 @@
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { EnsDomainLookupFiltersOptions, EnsLookupSorting } from 'types/api/ens';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ENS_DOMAIN } from 'stubs/ENS';
import { generateListStub } from 'stubs/utils';
import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address';
import { apos } from 'toolkit/utils/htmlEntities';
import NameDomainsActionBar from 'ui/nameDomains/NameDomainsActionBar';
import NameDomainsListItem from 'ui/nameDomains/NameDomainsListItem';
import NameDomainsTable from 'ui/nameDomains/NameDomainsTable';
import type { Sort, SortField } from 'ui/nameDomains/utils';
import { SORT_OPTIONS, getNextSortValue } from 'ui/nameDomains/utils';
import DataListDisplay from 'ui/shared/DataListDisplay';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import Clusters from 'ui/nameDomains/directories/Clusters';
import NameDomainsContent from 'ui/nameDomains/domains/NameDomainsContent';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
const NameDomains = () => {
const router = useRouter();
const q = getQueryParamString(router.query.name) || getQueryParamString(router.query.address);
const ownedBy = getQueryParamString(router.query.owned_by);
const resolvedTo = getQueryParamString(router.query.resolved_to);
const onlyActive = getQueryParamString(router.query.only_active);
const protocols = Array.isArray(router.query.protocols) ? router.query.protocols : (router.query.protocols ?? '').split(',').filter(Boolean);
const initialFilters: EnsDomainLookupFiltersOptions = [
ownedBy === 'true' ? 'owned_by' as const : undefined,
resolvedTo === 'true' ? 'resolved_to' as const : undefined,
onlyActive === 'false' ? 'with_inactive' as const : undefined,
const tabs: Array<TabItemRegular> = [
config.features.nameService.isEnabled && { id: 'domains', title: 'Domains', component: <NameDomainsContent/> },
config.features.clusters.isEnabled && { id: 'directories', title: 'Directories', component: <Clusters/> },
].filter(Boolean);
const initialSort = getSortValueFromQuery<Sort>(router.query, SORT_OPTIONS);
const [ searchTerm, setSearchTerm ] = React.useState<string>(q || '');
const [ filterValue, setFilterValue ] = React.useState<EnsDomainLookupFiltersOptions>(initialFilters);
const [ sort, setSort ] = React.useState<Sort>(initialSort ?? 'default');
const [ protocolsFilter, setProtocolsFilter ] = React.useState<Array<string>>(protocols);
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const isAddressSearch = React.useMemo(() => ADDRESS_REGEXP.test(debouncedSearchTerm), [ debouncedSearchTerm ]);
const sortParams = getSortParamsFromValue<Sort, EnsLookupSorting['sort'], EnsLookupSorting['order']>(sort);
const addressesLookupQuery = useQueryWithPages({
resourceName: 'bens:addresses_lookup',
pathParams: { chainId: config.chain.id },
filters: {
address: debouncedSearchTerm,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined,
},
sorting: sortParams,
options: {
enabled: isAddressSearch,
placeholderData: generateListStub<'bens:addresses_lookup'>(ENS_DOMAIN, 50, { next_page_params: undefined }),
},
});
const domainsLookupQuery = useQueryWithPages({
resourceName: 'bens:domains_lookup',
pathParams: { chainId: config.chain.id },
filters: {
name: debouncedSearchTerm,
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined,
},
sorting: sortParams,
options: {
enabled: !isAddressSearch,
placeholderData: generateListStub<'bens:domains_lookup'>(ENS_DOMAIN, 50, { next_page_params: undefined }),
},
});
const protocolsQuery = useApiQuery('bens:domain_protocols', {
pathParams: { chainId: config.chain.id },
});
const query = isAddressSearch ? addressesLookupQuery : domainsLookupQuery;
const { data, isError, isPlaceholderData: isLoading, onFilterChange, onSortingChange } = query;
React.useEffect(() => {
const hasInactiveFilter = filterValue.some((value) => value === 'with_inactive');
if (isAddressSearch) {
setFilterValue([ 'owned_by' as const, 'resolved_to' as const, hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean));
onFilterChange<'bens:addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: true,
owned_by: true,
only_active: !hasInactiveFilter,
protocols: protocolsFilter,
});
} else {
setFilterValue([ hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean));
onFilterChange<'bens:domains_lookup'>({
name: debouncedSearchTerm,
only_active: !hasInactiveFilter,
protocols: protocolsFilter,
});
}
// should run only the type of search changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isAddressSearch ]);
const handleSortToggle = React.useCallback((field: SortField) => {
if (isLoading) {
return;
}
if (field) {
setSort((prevValue) => {
const nextSortValue = getNextSortValue(field)(prevValue);
onSortingChange(getSortParamsFromValue(nextSortValue));
return nextSortValue;
});
}
}, [ isLoading, onSortingChange ]);
const handleSearchTermChange = React.useCallback((value: string) => {
setSearchTerm(value);
const isAddressSearch = ADDRESS_REGEXP.test(value);
if (isAddressSearch) {
onFilterChange<'bens:addresses_lookup'>({
address: value,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter,
});
} else {
onFilterChange<'bens:domains_lookup'>({
name: value,
only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter,
});
}
}, [ onFilterChange, filterValue, protocolsFilter ]);
const handleFilterValueChange = React.useCallback((value: EnsDomainLookupFiltersOptions) => {
setFilterValue(value);
const isAddressSearch = ADDRESS_REGEXP.test(debouncedSearchTerm);
if (isAddressSearch) {
onFilterChange<'bens:addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: value.includes('resolved_to'),
owned_by: value.includes('owned_by'),
only_active: !value.includes('with_inactive'),
protocols: protocolsFilter,
});
} else {
onFilterChange<'bens:domains_lookup'>({
name: debouncedSearchTerm,
only_active: !value.includes('with_inactive'),
protocols: protocolsFilter,
});
}
}, [ debouncedSearchTerm, onFilterChange, protocolsFilter ]);
const handleProtocolsFilterChange = React.useCallback((nextValue: Array<string>) => {
setProtocolsFilter(nextValue);
const isAddressSearch = ADDRESS_REGEXP.test(debouncedSearchTerm);
if (isAddressSearch) {
onFilterChange<'bens:addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
protocols: nextValue,
});
} else {
onFilterChange<'bens:domains_lookup'>({
name: debouncedSearchTerm,
only_active: !filterValue.includes('with_inactive'),
protocols: nextValue,
});
}
}, [ debouncedSearchTerm, filterValue, onFilterChange ]);
const hasActiveFilters = Boolean(debouncedSearchTerm) || filterValue.length > 0 ||
(protocolsQuery.data && protocolsQuery.data.items.length > 1 ? protocolsFilter.length > 0 : false);
const content = (
<>
<Box hideFrom="lg">
{ data?.items.map((item, index) => (
<NameDomainsListItem
key={ item.id + (isLoading ? index : '') }
{ ...item }
isLoading={ isLoading }
/>
)) }
</Box>
<Box hideBelow="lg">
<NameDomainsTable
data={ data }
isLoading={ isLoading }
sort={ sort }
onSortToggle={ handleSortToggle }
/>
</Box>
</>
);
const actionBar = (
<NameDomainsActionBar
isLoading={ isLoading }
searchTerm={ searchTerm }
onSearchChange={ handleSearchTermChange }
filterValue={ filterValue }
onFilterValueChange={ handleFilterValueChange }
protocolsData={ protocolsQuery.data?.items }
protocolsFilterValue={ protocolsFilter }
onProtocolsFilterChange={ handleProtocolsFilterChange }
sort={ sort }
onSortChange={ setSort }
isAddressSearch={ isAddressSearch }
pagination={ query.pagination }
/>
);
return (
<>
@@ -234,18 +20,7 @@ const NameDomains = () => {
title={ config.meta.seo.enhancedDataEnabled ? `${ config.chain.name } name domains` : 'Name services lookup' }
withTextAd
/>
<DataListDisplay
isError={ isError }
itemsNum={ data?.items.length }
emptyText="There are no name domains."
filterProps={{
emptyFilteredText: `Couldn${ apos }t find name domains that match your filter query.`,
hasActiveFilters,
}}
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
<RoutedTabs tabs={ tabs }/>
</>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Some files were not shown because too many files have changed in this diff Show More