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]>
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
### 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> |
|
||||
|
||||
|
||||
|
||||
### Metadata service integration
|
||||
|
||||
This feature allows name tags and other public tags for addresses.
|
||||
|
||||
@@ -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,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 |
@@ -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',
|
||||
|
||||
@@ -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 || '/';
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
||||
@@ -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() ]),
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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: [],
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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());
|
||||
@@ -38,6 +38,7 @@
|
||||
| "clock-light"
|
||||
| "clock"
|
||||
| "close"
|
||||
| "clusters"
|
||||
| "coins/bitcoin"
|
||||
| "collection"
|
||||
| "columns"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 101 KiB |