diff --git a/configs/app/apis.ts b/configs/app/apis.ts index d62917aea4..0a61c89b50 100644 --- a/configs/app/apis.ts +++ b/configs/app/apis.ts @@ -100,6 +100,17 @@ const rewardsApi = (() => { }); })(); +const clustersApi = (() => { + const apiHost = getEnvValue('NEXT_PUBLIC_CLUSTERS_API_HOST'); + if (!apiHost) { + return; + } + + return Object.freeze({ + endpoint: apiHost, + }); +})(); + const statsApi = (() => { const apiHost = getEnvValue('NEXT_PUBLIC_STATS_API_HOST'); if (!apiHost) { @@ -143,6 +154,7 @@ const apis: Apis = Object.freeze({ general: generalApi, admin: adminApi, bens: bensApi, + clusters: clustersApi, contractInfo: contractInfoApi, metadata: metadataApi, rewards: rewardsApi, diff --git a/configs/app/features/clusters.ts b/configs/app/features/clusters.ts new file mode 100644 index 0000000000..1e588860fa --- /dev/null +++ b/configs/app/features/clusters.ts @@ -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; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 9613e60c00..d970c941c8 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -8,6 +8,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'; diff --git a/configs/envs/.env.jest b/configs/envs/.env.jest index abe2107a80..d59bd3d1a8 100644 --- a/configs/envs/.env.jest +++ b/configs/envs/.env.jest @@ -50,3 +50,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 \ No newline at end of file diff --git a/configs/envs/.env.localhost b/configs/envs/.env.localhost index 3956c0d11c..b1c60c2f34 100644 --- a/configs/envs/.env.localhost +++ b/configs/envs/.env.localhost @@ -37,3 +37,7 @@ NEXT_PUBLIC_APP_ENV=development NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true NEXT_PUBLIC_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout + +# clusters feature +NEXT_PUBLIC_CLUSTERS_API_HOST=https://api.clusters.xyz +NEXT_PUBLIC_CLUSTERS_CDN_URL=https://cdn.clusters.xyz \ No newline at end of file diff --git a/configs/envs/.env.pw b/configs/envs/.env.pw index 65457a3352..bce229f4f1 100644 --- a/configs/envs/.env.pw +++ b/configs/envs/.env.pw @@ -59,4 +59,6 @@ NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx 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'] \ No newline at end of file +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 \ No newline at end of file diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 5878a89cc5..a56299517e 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -962,6 +962,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_GRAPHIQL_TRANSACTION: yup .mixed() diff --git a/deploy/values/review-l2/values.yaml.gotmpl b/deploy/values/review-l2/values.yaml.gotmpl index 3a91738499..f29cb02f79 100644 --- a/deploy/values/review-l2/values.yaml.gotmpl +++ b/deploy/values/review-l2/values.yaml.gotmpl @@ -71,6 +71,8 @@ frontend: NEXT_PUBLIC_USE_NEXT_JS_PROXY: true NEXT_PUBLIC_NAVIGATION_LAYOUT: horizontal NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: "['/blocks','/name-domains']" + NEXT_PUBLIC_CLUSTERS_API_HOST: https://api.clusters.xyz + NEXT_PUBLIC_CLUSTERS_CDN_URL: https://cdn.clusters.xyz envFromSecret: NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index ecc6d34aca..a66a716704 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -79,6 +79,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_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID 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 diff --git a/docs/ENVS.md b/docs/ENVS.md index ca630a2652..6d1cd01327 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -56,6 +56,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d - [Verified tokens info](#verified-tokens-info) - [Name service integration](#name-service-integration) - [Metadata service integration](#metadata-service-integration) + - [Clusters Universal Name Service](#clusters-universal-name-service) - [Public tag submission](#public-tag-submission) - [Data availability](#data-availability) - [Bridged tokens](#bridged-tokens) @@ -661,6 +662,17 @@ This feature allows name tags and other public tags for addresses.   +### Clusters Universal Name Service + +This feature integrates 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` | v2.2.0+ | +| 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` | v2.2.0+ | + +  + ### Public tag submission This feature allows you to submit an application with a public address tag. diff --git a/icons/clusters.svg b/icons/clusters.svg new file mode 100644 index 0000000000..b82f717b18 --- /dev/null +++ b/icons/clusters.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/icons/hourglass.svg b/icons/hourglass.svg index eafa49824b..7914f95d0e 100644 --- a/icons/hourglass.svg +++ b/icons/hourglass.svg @@ -1,4 +1,4 @@ - - + + diff --git a/jest.config.ts b/jest.config.ts index d718d56a7b..5450f44495 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -15,6 +15,9 @@ const config: JestConfigWithTsJest = { ], moduleNameMapper: { '^jest/(.*)': '/jest/$1', + '^nextjs-routes$': '/jest/mocks/nextjs-routes.js', + '\\.svg$': '/jest/mocks/svg.js', + '^@uidotdev/usehooks$': '/jest/mocks/usehooks.js', }, modulePathIgnorePatterns: [ 'node_modules_linux', diff --git a/jest/mocks/nextjs-routes.js b/jest/mocks/nextjs-routes.js new file mode 100644 index 0000000000..fe73627563 --- /dev/null +++ b/jest/mocks/nextjs-routes.js @@ -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 || '/'; + }), +}; diff --git a/jest/mocks/svg.js b/jest/mocks/svg.js new file mode 100644 index 0000000000..86059f3629 --- /dev/null +++ b/jest/mocks/svg.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/jest/mocks/usehooks.js b/jest/mocks/usehooks.js new file mode 100644 index 0000000000..016c3c47bf --- /dev/null +++ b/jest/mocks/usehooks.js @@ -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() ]), +}; diff --git a/lib/address/isEvmAddress.ts b/lib/address/isEvmAddress.ts new file mode 100644 index 0000000000..83db807b0b --- /dev/null +++ b/lib/address/isEvmAddress.ts @@ -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()); +} diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 711242be09..3b9a91bdb2 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -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'; @@ -27,6 +29,7 @@ import type { VisualizeApiResourceName, VisualizeApiResourcePayload } from './se 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, @@ -48,6 +51,7 @@ export type ResourcePath = string; export type ResourcePayload = R extends AdminApiResourceName ? AdminApiResourcePayload : R extends BensApiResourceName ? BensApiResourcePayload : +R extends ClustersApiResourceName ? ClustersApiResourcePayload : R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload : R extends GeneralApiResourceName ? GeneralApiResourcePayload : R extends MetadataApiResourceName ? MetadataApiResourcePayload : @@ -83,6 +87,7 @@ export type ResourceErrorAccount = ResourceError<{ errors: T }>; /* eslint-disable @stylistic/indent */ export type PaginationFilters = R extends BensApiResourceName ? BensApiPaginationFilters : +R extends ClustersApiResourceName ? ClustersApiPaginationFilters : R extends GeneralApiResourceName ? GeneralApiPaginationFilters : R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters : @@ -94,6 +99,7 @@ export const SORTING_FIELDS = [ 'sort', 'order' ]; /* eslint-disable @stylistic/indent */ export type PaginationSorting = R extends BensApiResourceName ? BensApiPaginationSorting : +R extends ClustersApiResourceName ? ClustersApiPaginationSorting : R extends GeneralApiResourceName ? GeneralApiPaginationSorting : never; /* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/clusters.ts b/lib/api/services/clusters.ts new file mode 100644 index 0000000000..738018dade --- /dev/null +++ b/lib/api/services/clusters.ts @@ -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; + +export type ClustersApiResourceName = `clusters:${ keyof typeof CLUSTERS_API_RESOURCES }`; + +export type ClustersApiResourcePayload = + 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 '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; diff --git a/lib/api/types.ts b/lib/api/types.ts index 4135650bca..449a54e6bb 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -1,4 +1,4 @@ -export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize' | 'tac'; +export type ApiName = 'general' | 'admin' | 'bens' | 'clusters' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize' | 'tac'; export interface ApiResource { path: string; diff --git a/lib/clusters/actionBarUtils.test.ts b/lib/clusters/actionBarUtils.test.ts new file mode 100644 index 0000000000..ebe25693d0 --- /dev/null +++ b/lib/clusters/actionBarUtils.test.ts @@ -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); + }); + }); +}); diff --git a/lib/clusters/actionBarUtils.ts b/lib/clusters/actionBarUtils.ts new file mode 100644 index 0000000000..f5cf63771d --- /dev/null +++ b/lib/clusters/actionBarUtils.ts @@ -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; +} diff --git a/lib/clusters/clustersUtils.test.ts b/lib/clusters/clustersUtils.test.ts new file mode 100644 index 0000000000..59c02c50e0 --- /dev/null +++ b/lib/clusters/clustersUtils.test.ts @@ -0,0 +1,204 @@ +import { + filterOwnedClusters, + getTotalRecordsDisplay, + getClusterLabel, + getClustersToShow, + getGridRows, + hasMoreClusters, + type ClusterData, +} from './clustersUtils'; + +describe('clustersUtils', () => { + const mockClusters: Array = [ + { + 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); + }); + }); +}); diff --git a/lib/clusters/clustersUtils.ts b/lib/clusters/clustersUtils.ts new file mode 100644 index 0000000000..d750724655 --- /dev/null +++ b/lib/clusters/clustersUtils.ts @@ -0,0 +1,29 @@ +import type { ClustersByAddressResponse } from 'types/api/clusters'; + +export type ClusterData = ClustersByAddressResponse['result']['data'][0]; + +export function filterOwnedClusters(clusters: Array, ownerAddress: string): Array { + 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, maxItems: number = 10): Array { + 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; +} diff --git a/lib/clusters/detectInputType.test.ts b/lib/clusters/detectInputType.test.ts new file mode 100644 index 0000000000..a9716ed8dd --- /dev/null +++ b/lib/clusters/detectInputType.test.ts @@ -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); + }); +}); diff --git a/lib/clusters/detectInputType.ts b/lib/clusters/detectInputType.ts new file mode 100644 index 0000000000..49bfae862a --- /dev/null +++ b/lib/clusters/detectInputType.ts @@ -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'; +} diff --git a/lib/clusters/pageUtils.test.ts b/lib/clusters/pageUtils.test.ts new file mode 100644 index 0000000000..46a8489e3b --- /dev/null +++ b/lib/clusters/pageUtils.test.ts @@ -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; + + 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); + }); + }); +}); diff --git a/lib/clusters/pageUtils.ts b/lib/clusters/pageUtils.ts new file mode 100644 index 0000000000..bb22e74c4f --- /dev/null +++ b/lib/clusters/pageUtils.ts @@ -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 { + if (!data || showDirectoryView) return []; + + if (data && typeof data === 'object' && 'result' in data) { + const result = (data as Record).result; + if (result && typeof result === 'object' && 'data' in result && Array.isArray(result.data)) { + return result.data as Array; + } + } + + return []; +} + +export function transformAddressDataToDirectory( + addressData: Array, + clusterDetails: unknown, +): Array { + 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; + } + return []; + }, + ) : []; + const uniqueChainIds = [ ...new Set(allChainIds) ] as Array; + + 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 { + 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 : 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; + } + } + + return []; +} + +export function applyDirectoryPagination( + fullDirectoryData: Array, + inputType: InputType, + page: number, + limit = 50, +): Array { + 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; +} diff --git a/lib/clusters/useAddressClusters.test.ts b/lib/clusters/useAddressClusters.test.ts new file mode 100644 index 0000000000..4c292b4645 --- /dev/null +++ b/lib/clusters/useAddressClusters.test.ts @@ -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, + }, + }); + }); +}); diff --git a/lib/clusters/useAddressClusters.ts b/lib/clusters/useAddressClusters.ts new file mode 100644 index 0000000000..914ba0cdb3 --- /dev/null +++ b/lib/clusters/useAddressClusters.ts @@ -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, + }, + }); +} diff --git a/lib/clusters/useClusterPagination.test.ts b/lib/clusters/useClusterPagination.test.ts new file mode 100644 index 0000000000..24dc11f9e8 --- /dev/null +++ b/lib/clusters/useClusterPagination.test.ts @@ -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; +const mockUseQueryParams = useQueryParams as jest.MockedFunction; +const mockGetQueryParamString = getQueryParamString as jest.MockedFunction; + +describe('useClusterPagination', () => { + const mockUpdateQuery = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseRouter.mockReturnValue({ + query: {}, + } as unknown as ReturnType); + 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); + }); + }); +}); diff --git a/lib/clusters/useClusterPagination.ts b/lib/clusters/useClusterPagination.ts new file mode 100644 index 0000000000..ee7fe5c057 --- /dev/null +++ b/lib/clusters/useClusterPagination.ts @@ -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, + }; +} diff --git a/lib/clusters/useClusterSearch.test.ts b/lib/clusters/useClusterSearch.test.ts new file mode 100644 index 0000000000..aaf83e0904 --- /dev/null +++ b/lib/clusters/useClusterSearch.test.ts @@ -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; + +describe('useClusterSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return search term from router query', () => { + mockUseRouter.mockReturnValue({ + query: { q: 'test-search' }, + } as unknown as ReturnType); + + 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); + + const { result } = renderHook(() => useClusterSearch()); + + expect(result.current.debouncedSearchTerm).toBe('test'); + }); + + it('should handle empty query', () => { + mockUseRouter.mockReturnValue({ + query: {}, + } as unknown as ReturnType); + + const { result } = renderHook(() => useClusterSearch()); + + expect(result.current.searchTerm).toBe(''); + expect(result.current.debouncedSearchTerm).toBe(''); + }); +}); diff --git a/lib/clusters/useClusterSearch.ts b/lib/clusters/useClusterSearch.ts new file mode 100644 index 0000000000..01848c2370 --- /dev/null +++ b/lib/clusters/useClusterSearch.ts @@ -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, + }; +} diff --git a/lib/clusters/useClustersData.test.ts b/lib/clusters/useClustersData.test.ts new file mode 100644 index 0000000000..81e0050249 --- /dev/null +++ b/lib/clusters/useClustersData.test.ts @@ -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; + +type MockQueryResult = ReturnType; + +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); + }); + }); +}); diff --git a/lib/clusters/useClustersData.ts b/lib/clusters/useClustersData.ts new file mode 100644 index 0000000000..ba46da8cf8 --- /dev/null +++ b/lib/clusters/useClustersData.ts @@ -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, + }; +} diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index b98c784ca0..064efdbe38 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -118,6 +118,14 @@ export default function useNavItems(): ReturnType { icon: 'MUD_menu', isActive: pathname === '/mud-worlds', } : null; + + const clustersLookup: NavItem | null = config.features.clusters.isEnabled ? { + text: 'Clusters lookup', + nextRoute: { pathname: '/clusters' as const }, + icon: 'clusters', + isActive: pathname === '/clusters' || pathname === '/clusters/[name]', + } : null; + const epochs = config.features.celo.isEnabled ? { text: 'Epochs', nextRoute: { pathname: '/epochs' as const }, @@ -161,6 +169,7 @@ export default function useNavItems(): ReturnType { validators, verifiedContracts, ensLookup, + clustersLookup, ].filter(Boolean), ]; } else if (rollupFeature.isEnabled && rollupFeature.type === 'shibarium') { @@ -177,6 +186,7 @@ export default function useNavItems(): ReturnType { topAccounts, verifiedContracts, ensLookup, + clustersLookup, ].filter(Boolean), ]; } else if (rollupFeature.isEnabled && rollupFeature.type === 'zkSync') { @@ -193,6 +203,7 @@ export default function useNavItems(): ReturnType { validators, verifiedContracts, ensLookup, + clustersLookup, ].filter(Boolean), ]; } else { @@ -207,6 +218,7 @@ export default function useNavItems(): ReturnType { validators, verifiedContracts, ensLookup, + clustersLookup, config.features.beaconChain.isEnabled && { text: 'Withdrawals', nextRoute: { pathname: '/withdrawals' as const }, diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index c3363f0d84..e1cfa02ed2 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -23,6 +23,8 @@ const OG_TYPE_DICT: Record = { '/token/[hash]/instance/[id]': 'Regular page', '/apps': 'Root page', '/apps/[id]': 'Regular page', + '/clusters': 'Root page', + '/clusters/[name]': 'Regular page', '/stats': 'Root page', '/stats/[id]': 'Regular page', '/api-docs': 'Regular page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index 2b9322703e..738eb25ff5 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -26,6 +26,8 @@ const TEMPLATE_MAP: Record = { '/token/[hash]/instance/[id]': '%hash%, balances and analytics on the %network_title%', '/apps': DEFAULT_TEMPLATE, '/apps/[id]': DEFAULT_TEMPLATE, + '/clusters': '%network_name% clusters | %app_name%', + '/clusters/[name]': '%cluster_name% cluster | %app_name%', '/stats': DEFAULT_TEMPLATE, '/stats/[id]': DEFAULT_TEMPLATE, '/api-docs': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index 7338ba87fa..7f9081d886 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -65,6 +65,8 @@ const TEMPLATE_MAP: Record = { '/interop-messages': '%network_name% interop messages', '/operations': '%network_name% operations', '/operation/[id]': '%network_name% operation %id%', + '/clusters': 'Clusters universal name service', + '/clusters/[name]': 'Clusters details for %name%', // service routes, added only to make typescript happy '/login': '%network_name% login', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index 80105e3fd9..405e6fc73f 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -21,6 +21,8 @@ export const PAGE_TYPE_DICT: Record = { '/token/[hash]/instance/[id]': 'Token Instance', '/apps': 'DApps', '/apps/[id]': 'DApp', + '/clusters': 'Clusters', + '/clusters/[name]': 'Cluster details', '/stats': 'Stats', '/stats/[id]': 'Stats chart', '/api-docs': 'REST API', diff --git a/lib/router/useQueryParams.ts b/lib/router/useQueryParams.ts new file mode 100644 index 0000000000..c4eead5e77 --- /dev/null +++ b/lib/router/useQueryParams.ts @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router'; +import { useCallback } from 'react'; + +export function useQueryParams() { + const router = useRouter(); + + const updateQuery = useCallback((updates: Record) => { + 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 }; +} diff --git a/mocks/clusters/cluster.ts b/mocks/clusters/cluster.ts new file mode 100644 index 0000000000..0979b85023 --- /dev/null +++ b/mocks/clusters/cluster.ts @@ -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, + }, + }, +}; diff --git a/mocks/clusters/directory.ts b/mocks/clusters/directory.ts new file mode 100644 index 0000000000..5e39b6dd36 --- /dev/null +++ b/mocks/clusters/directory.ts @@ -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: [], + }, + }, +}; diff --git a/mocks/clusters/leaderboard.ts b/mocks/clusters/leaderboard.ts new file mode 100644 index 0000000000..437b9ce499 --- /dev/null +++ b/mocks/clusters/leaderboard.ts @@ -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: [], + }, +}; diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index a77afde725..c411248d52 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -247,6 +247,16 @@ export const nameService: GetServerSideProps = async(context) => { return base(context); }; +export const clusters: GetServerSideProps = async(context) => { + if (!config.features.clusters.isEnabled) { + return { + notFound: true, + }; + } + + return base(context); +}; + export const accounts: GetServerSideProps = async(context) => { if (config.UI.views.address.hiddenViews?.top_accounts) { return { diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index cac9cedeba..d2fafd894d 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -39,6 +39,8 @@ declare module "nextjs-routes" { | StaticRoute<"/block/countdown"> | StaticRoute<"/blocks"> | StaticRoute<"/chakra"> + | DynamicRoute<"/clusters/[name]", { "name": string }> + | StaticRoute<"/clusters"> | StaticRoute<"/contract-verification"> | StaticRoute<"/csv-export"> | StaticRoute<"/deposits"> diff --git a/pages/clusters/[name].tsx b/pages/clusters/[name].tsx new file mode 100644 index 0000000000..5f368e9581 --- /dev/null +++ b/pages/clusters/[name].tsx @@ -0,0 +1,20 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Props } from 'nextjs/getServerSideProps'; +import PageNextJs from 'nextjs/PageNextJs'; + +const Cluster = dynamic(() => import('ui/pages/Cluster'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { clusters as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/clusters/index.tsx b/pages/clusters/index.tsx new file mode 100644 index 0000000000..582b3ce487 --- /dev/null +++ b/pages/clusters/index.tsx @@ -0,0 +1,19 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import PageNextJs from 'nextjs/PageNextJs'; + +const Clusters = dynamic(() => import('ui/pages/Clusters'), { ssr: false }); + +const Page: NextPage = () => { + return ( + + + + ); +}; + +export default Page; + +export { clusters as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts index 6b3020c4e4..ff5ea9237a 100644 --- a/playwright/fixtures/mockEnvs.ts +++ b/playwright/fixtures/mockEnvs.ts @@ -112,4 +112,7 @@ export const ENVS_MAP: Record> = { celo: [ [ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ], ], + clusters: [ + [ 'NEXT_PUBLIC_CLUSTERS_API_HOST', 'https://api.clusters.xyz' ], + ], }; diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 461b06acd9..8a562c77ef 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -38,6 +38,7 @@ | "clock-light" | "clock" | "close" + | "clusters" | "coins/bitcoin" | "collection" | "columns" diff --git a/stubs/clusters.ts b/stubs/clusters.ts new file mode 100644 index 0000000000..99a896ba06 --- /dev/null +++ b/stubs/clusters.ts @@ -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, +}; diff --git a/toolkit/components/forms/validators/address.ts b/toolkit/components/forms/validators/address.ts index d85d1858ae..28da0a75fd 100644 --- a/toolkit/components/forms/validators/address.ts +++ b/toolkit/components/forms/validators/address.ts @@ -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; diff --git a/toolkit/theme/foundations/colors.ts b/toolkit/theme/foundations/colors.ts index a957d1c37c..e0c6736d4b 100644 --- a/toolkit/theme/foundations/colors.ts +++ b/toolkit/theme/foundations/colors.ts @@ -157,6 +157,7 @@ const colors = { medium: { value: '#231F20' }, reddit: { value: '#FF4500' }, celo: { value: '#FCFF52' }, + clusters: { value: '#DE6061' }, }; export default colors; diff --git a/toolkit/utils/regexp.ts b/toolkit/utils/regexp.ts index 6667a6e064..13f0d37e6b 100644 --- a/toolkit/utils/regexp.ts +++ b/toolkit/utils/regexp.ts @@ -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}$/; diff --git a/types/api/clusters.ts b/types/api/clusters.ts new file mode 100644 index 0000000000..275213b7ba --- /dev/null +++ b/types/api/clusters.ts @@ -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; + }; +} + +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; + }>; + isTestnet: boolean; + }; + }; +} + +export interface ClustersLeaderboardObject { + name: string; + clusterId: string; + isTestnet: boolean; + totalWeiAmount: string; + totalReferralAmount: string; + chainIds: Array; + nameCount: string; + rank: number; +} + +export interface ClustersLeaderboardResponse { + result: { + data: Array; + }; +} + +export interface ClustersDirectoryObject { + name: string; + isTestnet: boolean; + createdAt: string; + owner: string; + totalWeiAmount: string; + updatedAt: string; + updatedBy: string; + chainIds: Array; +} + +export interface ClustersDirectoryResponse { + result: { + data: { + total: number; + items: Array; + }; + }; +} + +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; +} diff --git a/types/api/search.ts b/types/api/search.ts index da2952b899..21a787561d 100644 --- a/types/api/search.ts +++ b/types/api/search.ts @@ -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; diff --git a/ui/address/clusters/AddressClusters.tsx b/ui/address/clusters/AddressClusters.tsx new file mode 100644 index 0000000000..4984f43a14 --- /dev/null +++ b/ui/address/clusters/AddressClusters.tsx @@ -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>; + addressHash: string; +} + +interface ClustersGridProps { + data: ClustersByAddressResponse['result']['data']; +} + +const ClustersGrid = ({ data }: ClustersGridProps) => { + const itemsToShow = getClustersToShow(data, 10); + const numberOfRows = getGridRows(itemsToShow.length, 5); + + return ( + + { itemsToShow.map((cluster) => ( + + )) } + + ); +}; + +const AddressClusters = ({ query, addressHash }: Props) => { + const { data, isPending, isError } = query; + + if (isError) { + return null; + } + + if (isPending) { + return ; + } + + 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 ( + + +
+ + + +
+
+ + +
+ Attached to this address + +
+ { showMoreLink && ( + + More results + ({ totalRecords }) + + ) } +
+
+
+ ); +}; + +export default AddressClusters; diff --git a/ui/cluster/ClusterDetails.tsx b/ui/cluster/ClusterDetails.tsx new file mode 100644 index 0000000000..9e3b7ebe4e --- /dev/null +++ b/ui/cluster/ClusterDetails.tsx @@ -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 ( + + + Cluster Name + + + + + + + Owner address + + + + + + + Type + + + + { addressType } + + + + + Backing + + + + + + + Created + + + { clusterData?.createdAt ? ( + + ) : ( + N/A + ) } + + + ); +}; + +export default ClusterDetails; diff --git a/ui/clusters/ClustersActionBar.tsx b/ui/clusters/ClustersActionBar.tsx new file mode 100644 index 0000000000..1e2076b3a7 --- /dev/null +++ b/ui/clusters/ClustersActionBar.tsx @@ -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 = ( + + + + + + + + ); + + return ( + <> + + { filters } + + + + { filters } + + + + + ); +}; + +export default React.memo(ClustersActionBar); diff --git a/ui/clusters/ClustersDirectoryListItem.tsx b/ui/clusters/ClustersDirectoryListItem.tsx new file mode 100644 index 0000000000..3a5be5047f --- /dev/null +++ b/ui/clusters/ClustersDirectoryListItem.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import type { ClustersDirectoryObject } from 'types/api/clusters'; + +import { isEvmAddress } from 'lib/address/isEvmAddress'; +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'; +import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; + +interface Props { + item: ClustersDirectoryObject; + isLoading?: boolean; + isClusterDetailsLoading?: boolean; +} + +const ClustersDirectoryListItem = ({ item, isLoading, isClusterDetailsLoading }: Props) => { + return ( + + + Cluster Name + + + + + + + Address + + + { item.owner && ( + + ) } + { !item.owner && — } + + + + Joined + + + + + + + Active Chains + + + + { (item.chainIds?.length || 1) } { (item.chainIds?.length || 1) === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersDirectoryListItem); diff --git a/ui/clusters/ClustersDirectoryTable.tsx b/ui/clusters/ClustersDirectoryTable.tsx new file mode 100644 index 0000000000..ab643f6260 --- /dev/null +++ b/ui/clusters/ClustersDirectoryTable.tsx @@ -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; + isLoading?: boolean; + top?: number; + isClusterDetailsLoading?: boolean; +} + +const ClustersDirectoryTable = ({ data, isLoading, top, isClusterDetailsLoading }: Props) => { + return ( + + + + Cluster Name + Address + + Joined + + + Active Chains + + + + { data.map((item, index) => ( + + )) } + + + ); +}; + +export default React.memo(ClustersDirectoryTable); diff --git a/ui/clusters/ClustersDirectoryTableItem.tsx b/ui/clusters/ClustersDirectoryTableItem.tsx new file mode 100644 index 0000000000..b4a43ccd0e --- /dev/null +++ b/ui/clusters/ClustersDirectoryTableItem.tsx @@ -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 ( + + + + + + { item.owner && ( + + ) } + { !item.owner && — } + + + + + + + { (item.chainIds?.length || 1) } { (item.chainIds?.length || 1) === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersDirectoryTableItem); diff --git a/ui/clusters/ClustersLeaderboardListItem.tsx b/ui/clusters/ClustersLeaderboardListItem.tsx new file mode 100644 index 0000000000..518e13734b --- /dev/null +++ b/ui/clusters/ClustersLeaderboardListItem.tsx @@ -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 ( + + + Rank + + + + #{ item.rank } + + + + + Cluster Name + + + + + + + Names + + + + { item.nameCount } + + + + + Backing + + + + { (parseFloat(item.totalWeiAmount) / 1e18).toFixed(2) } ETH + + + + + Network Presence + + + + { item.chainIds.length } { item.chainIds.length === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersLeaderboardListItem); diff --git a/ui/clusters/ClustersLeaderboardTable.tsx b/ui/clusters/ClustersLeaderboardTable.tsx new file mode 100644 index 0000000000..f667a5cab6 --- /dev/null +++ b/ui/clusters/ClustersLeaderboardTable.tsx @@ -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; + isLoading?: boolean; + top?: number; +} + +const ClustersLeaderboardTable = ({ data, isLoading, top }: Props) => { + return ( + + + + Rank + Cluster Name + Names + Total Backing + Active Chains + + + + { data.map((item, index) => ( + + )) } + + + ); +}; + +export default React.memo(ClustersLeaderboardTable); diff --git a/ui/clusters/ClustersLeaderboardTableItem.tsx b/ui/clusters/ClustersLeaderboardTableItem.tsx new file mode 100644 index 0000000000..b75c57d6a3 --- /dev/null +++ b/ui/clusters/ClustersLeaderboardTableItem.tsx @@ -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 ( + + + + #{ item.rank } + + + + + + + + { item.nameCount } + + + + + { (parseFloat(item.totalWeiAmount) / 1e18).toFixed(2) } ETH + + + + + { item.chainIds.length } { item.chainIds.length === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersLeaderboardTableItem); diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 36c27c4c67..53e65e7fe2 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -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 { useAppContext } from 'lib/contexts/app'; import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; @@ -36,6 +37,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 useContractTabs from 'ui/address/contract/useContractTabs'; import { CONTRACT_TAB_IDS } from 'ui/address/contract/utils'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; @@ -130,6 +132,8 @@ const AddressPageContent = () => { addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) : undefined; + const addressClustersQuery = useAddressClusters(hash, areQueriesEnabled); + const isLoading = addressQuery.isPlaceholderData; const isTabsLoading = isLoading || @@ -433,6 +437,8 @@ const AddressPageContent = () => { } { !isLoading && addressEnsDomainsQuery.data && config.features.nameService.isEnabled && } + { !isLoading && addressClustersQuery.data && config.features.clusters.isEnabled && + } ); diff --git a/ui/pages/Cluster.pw.tsx b/ui/pages/Cluster.pw.tsx new file mode 100644 index 0000000000..4562d4f57b --- /dev/null +++ b/ui/pages/Cluster.pw.tsx @@ -0,0 +1,83 @@ +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'; + +test.beforeEach(async({ mockEnvs, mockTextAd }) => { + await mockEnvs(ENVS_MAP.clusters); + await mockTextAd(); +}); + +test.describe('Cluster Details Page', () => { + test('mainnet cluster details +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => { + await mockAssetResponse( + 'https://cdn.clusters.xyz/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( +
+ + +
, + { + hooksConfig: { + router: { + query: { name: 'campnetwork/lol' }, + isReady: true, + }, + }, + }, + ); + + await expect(component.getByText('campnetwork/lol').first()).toBeVisible(); + await expect(component.getByText('Cluster Name')).toBeVisible(); + await expect(component.getByText('Owner address')).toBeVisible(); + await expect(component.getByText('Backing')).toBeVisible(); + + await expect(component).toHaveScreenshot(); + }); + + test('testnet cluster details +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => { + await mockAssetResponse( + 'https://cdn.clusters.xyz/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( +
+ + +
, + { + hooksConfig: { + router: { + query: { name: 'test/cluster' }, + isReady: true, + }, + }, + }, + ); + + await expect(component.getByText('test/cluster').first()).toBeVisible(); + await expect(component.getByText('Cluster Name')).toBeVisible(); + await expect(component.getByText('Owner address')).toBeVisible(); + await expect(component.getByText('Backing')).toBeVisible(); + + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/pages/Cluster.tsx b/ui/pages/Cluster.tsx new file mode 100644 index 0000000000..c8576d1f27 --- /dev/null +++ b/ui/pages/Cluster.tsx @@ -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 ( + <> + + + + + ); +}; + +export default Cluster; diff --git a/ui/pages/Clusters.pw.tsx b/ui/pages/Clusters.pw.tsx new file mode 100644 index 0000000000..e03d82bd36 --- /dev/null +++ b/ui/pages/Clusters.pw.tsx @@ -0,0 +1,63 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { clustersDirectoryMock } from 'mocks/clusters/directory'; +import { clustersLeaderboardMock } from 'mocks/clusters/leaderboard'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import Clusters from './Clusters'; + +test.beforeEach(async({ mockEnvs, mockTextAd }) => { + await mockEnvs(ENVS_MAP.clusters); + await mockTextAd(); +}); + +test.describe('Clusters Directory Page', () => { + test.describe('mobile', () => { + test('clusters directory with data +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/campnetwork/lol', './playwright/mocks/image_s.jpg'); + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/duck/quack', './playwright/mocks/image_s.jpg'); + await mockAssetResponse('https://cdn.clusters.xyz/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', + }), + }, + }); + + const component = await render( +
+ + +
, + { + hooksConfig: { + router: { + isReady: true, + }, + }, + }, + ); + + await expect(component.getByRole('link', { name: 'campnetwork/lol' })).toBeVisible({ timeout: 10000 }); + await expect(component.getByRole('link', { name: 'duck/quack' })).toBeVisible({ timeout: 10000 }); + await expect(component.getByRole('link', { name: 'test/cluster' })).toBeVisible({ timeout: 10000 }); + + await expect(component).toHaveScreenshot(); + }); + }); +}); diff --git a/ui/pages/Clusters.tsx b/ui/pages/Clusters.tsx new file mode 100644 index 0000000000..82c601349b --- /dev/null +++ b/ui/pages/Clusters.tsx @@ -0,0 +1,156 @@ +import { Box } 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 ClustersActionBar from 'ui/clusters/ClustersActionBar'; +import ClustersDirectoryListItem from 'ui/clusters/ClustersDirectoryListItem'; +import ClustersDirectoryTable from 'ui/clusters/ClustersDirectoryTable'; +import ClustersLeaderboardListItem from 'ui/clusters/ClustersLeaderboardListItem'; +import ClustersLeaderboardTable from 'ui/clusters/ClustersLeaderboardTable'; +import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import PageTitle from 'ui/shared/Page/PageTitle'; + +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 = ( + <> + + { showDirectoryView ? ( + directoryData.map((item, index) => ( + + )) + ) : ( + leaderboardData.map((item, index) => ( + + )) + ) } + + + { showDirectoryView ? ( + + ) : ( + + ) } + + + ); + + const actionBar = ( + + ); + + return ( + <> + + + { content } + + + ); +}; + +export default Clusters; diff --git a/ui/pages/NameDomains.tsx b/ui/pages/NameDomains.tsx index 68f496fba0..e256d03038 100644 --- a/ui/pages/NameDomains.tsx +++ b/ui/pages/NameDomains.tsx @@ -10,8 +10,8 @@ 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 { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import NameDomainsActionBar from 'ui/nameDomains/NameDomainsActionBar'; import NameDomainsListItem from 'ui/nameDomains/NameDomainsListItem'; import NameDomainsTable from 'ui/nameDomains/NameDomainsTable'; diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..d3f17df558 Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-testnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-testnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..45b3fc4520 Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-testnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..3f11f96abd Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-testnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-testnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..e055c8c04f Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-testnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Clusters.pw.tsx_default_Clusters-Directory-Page-mobile-clusters-directory-with-data-mobile-1.png b/ui/pages/__screenshots__/Clusters.pw.tsx_default_Clusters-Directory-Page-mobile-clusters-directory-with-data-mobile-1.png new file mode 100644 index 0000000000..f07793a4b4 Binary files /dev/null and b/ui/pages/__screenshots__/Clusters.pw.tsx_default_Clusters-Directory-Page-mobile-clusters-directory-with-data-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Clusters.pw.tsx_mobile_Clusters-Directory-Page-mobile-clusters-directory-with-data-mobile-1.png b/ui/pages/__screenshots__/Clusters.pw.tsx_mobile_Clusters-Directory-Page-mobile-clusters-directory-with-data-mobile-1.png new file mode 100644 index 0000000000..9b64ce9f3d Binary files /dev/null and b/ui/pages/__screenshots__/Clusters.pw.tsx_mobile_Clusters-Directory-Page-mobile-clusters-directory-with-data-mobile-1.png differ diff --git a/ui/searchResults/SearchResultListItem.tsx b/ui/searchResults/SearchResultListItem.tsx index 8f34864104..2e3684ade8 100644 --- a/ui/searchResults/SearchResultListItem.tsx +++ b/ui/searchResults/SearchResultListItem.tsx @@ -17,7 +17,7 @@ import { Image } from 'toolkit/chakra/image'; import { Link } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { Tag } from 'toolkit/chakra/tag'; -import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address'; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; diff --git a/ui/searchResults/SearchResultTableItem.tsx b/ui/searchResults/SearchResultTableItem.tsx index df8362985f..287c0e1f16 100644 --- a/ui/searchResults/SearchResultTableItem.tsx +++ b/ui/searchResults/SearchResultTableItem.tsx @@ -18,7 +18,7 @@ import { Link } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { TableCell, TableRow } from 'toolkit/chakra/table'; import { Tag } from 'toolkit/chakra/tag'; -import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address'; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; diff --git a/ui/shared/ClusterIcon.tsx b/ui/shared/ClusterIcon.tsx new file mode 100644 index 0000000000..474f896dcd --- /dev/null +++ b/ui/shared/ClusterIcon.tsx @@ -0,0 +1,63 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { getFeaturePayload } from 'configs/app/features/types'; + +import config from 'configs/app'; +import { Image } from 'toolkit/chakra/image'; +import type { ImageProps } from 'toolkit/chakra/image'; +import IconSvg from 'ui/shared/IconSvg'; + +interface ClusterIconProps extends Omit { + clusterName: string; +} + +const ClusterIcon = ({ + clusterName, + boxSize = 5, + borderRadius = 'base', + mr = 2, + flexShrink = 0, + ...imageProps +}: ClusterIconProps) => { + const clustersFeature = getFeaturePayload(config.features.clusters); + + const fallbackElement = ( + + + + ); + + if (!clustersFeature) { + return fallbackElement; + } + + return ( + { + ); +}; + +export default React.memo(ClusterIcon); diff --git a/ui/shared/entities/clusters/ClustersEntity.pw.tsx b/ui/shared/entities/clusters/ClustersEntity.pw.tsx new file mode 100644 index 0000000000..e88c280838 --- /dev/null +++ b/ui/shared/entities/clusters/ClustersEntity.pw.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import { longNameCluster } from 'mocks/clusters/directory'; +import { test, expect } from 'playwright/lib'; + +import ClustersEntity from './ClustersEntity'; + +test.describe('basic display', () => { + test('basic cluster entity +@mobile', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component.getByText('example.cluster/')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); + + test('cluster with subdomain +@mobile', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/test/subdomain', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component.getByText('test/subdomain')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); +}); + +test.describe('variants', () => { + test('heading variant +@mobile', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); + }); + + test('subheading variant +@mobile', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); + }); +}); + +test.describe('customization', () => { + test('no link +@mobile', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component.getByText('example.cluster/')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); + + test('no icon +@mobile', async({ render }) => { + const component = await render( + , + ); + + await expect(component.getByText('example.cluster/')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); +}); + +test('long cluster name truncation +@mobile', async({ render, mockAssetResponse }) => { + await mockAssetResponse( + 'https://cdn.clusters.xyz/profile-image/this-is-a-very-long-cluster-name-that-should-test-truncation/subdomain', './playwright/mocks/image_s.jpg', + ); + const component = await render( + , + ); + + await expect(component.getByText(/this-is-a-very-long/)).toBeVisible(); + await expect(component).toHaveScreenshot(); +}); + +test('hover interaction +@mobile', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await component.hover(); + await expect(component).toHaveScreenshot(); +}); + +test('dark mode +@dark-mode', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/shared/entities/clusters/ClustersEntity.test.tsx b/ui/shared/entities/clusters/ClustersEntity.test.tsx new file mode 100644 index 0000000000..bfa2fbba57 --- /dev/null +++ b/ui/shared/entities/clusters/ClustersEntity.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { render, screen } from 'jest/lib'; + +import ClustersEntity from './ClustersEntity'; + +describe('ClustersEntity', () => { + const mockClusterName = 'test-cluster'; + + it('should render cluster name with slash', () => { + render(); + + expect(screen.getByText('test-cluster/')).toBeTruthy(); + }); + + it('should render cluster icon', () => { + render(); + + const icon = screen.getByAltText('test-cluster profile'); + expect(icon).toBeTruthy(); + }); + + it('should link to cluster details page', () => { + render(); + + const link = screen.getByRole('link'); + expect(link.getAttribute('href')).toBe('/clusters/test-cluster'); + }); + + it('should render without link when noLink is true', () => { + render(); + + expect(screen.queryByRole('link')).toBeNull(); + expect(screen.getByText('test-cluster/')).toBeTruthy(); + }); + + it('should show loading skeleton when loading', () => { + render(); + + const skeletons = document.querySelectorAll('.chakra-skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + }); +}); diff --git a/ui/shared/entities/clusters/ClustersEntity.tsx b/ui/shared/entities/clusters/ClustersEntity.tsx new file mode 100644 index 0000000000..db040e78d0 --- /dev/null +++ b/ui/shared/entities/clusters/ClustersEntity.tsx @@ -0,0 +1,192 @@ +import { Box, chakra, Flex, Text } from '@chakra-ui/react'; +import React from 'react'; + +import { getFeaturePayload } from 'configs/app/features/types'; + +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import { Image } from 'toolkit/chakra/image'; +import { Link as LinkToolkit } from 'toolkit/chakra/link'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import * as EntityBase from 'ui/shared/entities/base/components'; +import IconSvg from 'ui/shared/IconSvg'; + +import { distributeEntityProps, getIconProps } from '../base/utils'; + +type LinkProps = EntityBase.LinkBaseProps & Pick; + +const Link = chakra((props: LinkProps) => { + const defaultHref = route({ pathname: '/clusters/[name]', query: { name: encodeURIComponent(props.clusterName) } }); + + return ( + + { props.children } + + ); +}); + +type IconProps = EntityBase.IconBaseProps & Pick; + +const Icon = (props: IconProps) => { + if (props.noIcon) { + return null; + } + + const styles = getIconProps(props.variant); + + if (props.isLoading) { + return ; + } + + const fallbackElement = ( + + + + ); + + const profileImageElement = ( + { + ); + + const tooltipContent = ( + <> + + + + +
+ Clusters + - Universal name service +
+
+ + Clusters provides unified naming across multiple blockchains including EVM, Solana, Bitcoin, and more. + Manage all your wallet addresses under one human-readable name. + + + + Learn more about Clusters + + + ); + + return ( + + { profileImageElement } + + ); +}; + +type ContentProps = Omit & Pick; + +const Content = chakra((props: ContentProps) => { + const shouldShowTrailingSlash = !props.clusterName.includes('/'); + const displayName = shouldShowTrailingSlash ? `${ props.clusterName }/` : props.clusterName; + + return ( + + ); +}); + +type CopyProps = Omit & Pick; + +const Copy = (props: CopyProps) => { + return ( + + ); +}; + +const Container = EntityBase.Container; + +export interface EntityProps extends EntityBase.EntityBaseProps { + clusterName: string; +} + +const ClustersEntity = (props: EntityProps) => { + const partsProps = distributeEntityProps(props); + const content = ; + + return ( + + + { props.noLink ? content : { content } } + + + ); +}; + +export default React.memo(chakra(ClustersEntity)); + +export { + Container, + Link, + Icon, + Content, + Copy, +}; diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_dark-color-mode_dark-mode-dark-mode-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_dark-color-mode_dark-mode-dark-mode-1.png new file mode 100644 index 0000000000..2fd291a506 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_dark-color-mode_dark-mode-dark-mode-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-basic-cluster-entity-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-basic-cluster-entity-mobile-1.png new file mode 100644 index 0000000000..ff91bffd2b Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-basic-cluster-entity-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-cluster-with-subdomain-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-cluster-with-subdomain-mobile-1.png new file mode 100644 index 0000000000..65cc050482 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-cluster-with-subdomain-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-icon-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-icon-mobile-1.png new file mode 100644 index 0000000000..dc8d914079 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-icon-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-link-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-link-mobile-1.png new file mode 100644 index 0000000000..077f1651ac Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-link-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_dark-mode-dark-mode-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_dark-mode-dark-mode-1.png new file mode 100644 index 0000000000..ff91bffd2b Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_dark-mode-dark-mode-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_hover-interaction-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_hover-interaction-mobile-1.png new file mode 100644 index 0000000000..ff91bffd2b Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_hover-interaction-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_long-cluster-name-truncation-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_long-cluster-name-truncation-mobile-1.png new file mode 100644 index 0000000000..d2fce53652 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_long-cluster-name-truncation-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-heading-variant-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-heading-variant-mobile-1.png new file mode 100644 index 0000000000..08e88944ce Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-heading-variant-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-subheading-variant-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-subheading-variant-mobile-1.png new file mode 100644 index 0000000000..b8dd2e6972 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-subheading-variant-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_basic-display-basic-cluster-entity-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_basic-display-basic-cluster-entity-mobile-1.png new file mode 100644 index 0000000000..9668ed8d8b Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_basic-display-basic-cluster-entity-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_basic-display-cluster-with-subdomain-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_basic-display-cluster-with-subdomain-mobile-1.png new file mode 100644 index 0000000000..042fad385a Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_basic-display-cluster-with-subdomain-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_customization-no-icon-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_customization-no-icon-mobile-1.png new file mode 100644 index 0000000000..5474c2153a Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_customization-no-icon-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_customization-no-link-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_customization-no-link-mobile-1.png new file mode 100644 index 0000000000..56d7e3ccbc Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_customization-no-link-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_hover-interaction-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_hover-interaction-mobile-1.png new file mode 100644 index 0000000000..9668ed8d8b Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_hover-interaction-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_long-cluster-name-truncation-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_long-cluster-name-truncation-mobile-1.png new file mode 100644 index 0000000000..29577025ba Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_long-cluster-name-truncation-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_variants-heading-variant-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_variants-heading-variant-mobile-1.png new file mode 100644 index 0000000000..1e4483ad9d Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_variants-heading-variant-mobile-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_variants-subheading-variant-mobile-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_variants-subheading-variant-mobile-1.png new file mode 100644 index 0000000000..d66124ef72 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_mobile_variants-subheading-variant-mobile-1.png differ diff --git a/ui/shared/search/utils.ts b/ui/shared/search/utils.ts index 7c387b5ad3..1d07877b04 100644 --- a/ui/shared/search/utils.ts +++ b/ui/shared/search/utils.ts @@ -3,7 +3,18 @@ import type { SearchResultItem } from 'types/client/search'; import config from 'configs/app'; -export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain' | 'tac_operation'; +export type ApiCategory = + | 'token' + | 'nft' + | 'address' + | 'public_tag' + | 'transaction' + | 'block' + | 'user_operation' + | 'blob' + | 'domain' + | 'cluster' + | 'tac_operation'; export type Category = ApiCategory | 'app'; export type ItemsCategoriesMap = @@ -38,9 +49,14 @@ if (config.features.nameService.isEnabled) { searchCategories.unshift({ id: 'domain', title: 'Names' }); } +if (config.features.clusters.isEnabled) { + searchCategories.unshift({ id: 'cluster', title: 'Cluster Name' }); +} + export const searchItemTitles: Record = { app: { itemTitle: 'DApp', itemTitleShort: 'App' }, domain: { itemTitle: 'Name', itemTitleShort: 'Name' }, + cluster: { itemTitle: 'Cluster', itemTitleShort: 'Cluster' }, token: { itemTitle: 'Token', itemTitleShort: 'Token' }, nft: { itemTitle: 'NFT', itemTitleShort: 'NFT' }, address: { itemTitle: 'Address', itemTitleShort: 'Address' }, @@ -86,6 +102,9 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C case 'ens_domain': { return 'domain'; } + case 'cluster': { + return 'cluster'; + } case 'tac_operation': { return 'tac_operation'; } diff --git a/ui/snippets/navigation/NavLinkIcon.tsx b/ui/snippets/navigation/NavLinkIcon.tsx index 21ba3c7c50..7ee0deade2 100644 --- a/ui/snippets/navigation/NavLinkIcon.tsx +++ b/ui/snippets/navigation/NavLinkIcon.tsx @@ -12,7 +12,7 @@ interface Props { const NavLinkIcon = ({ item, className }: Props) => { if ('icon' in item && item.icon) { - return ; + return ; } if ('iconComponent' in item && item.iconComponent) { const IconComponent = item.iconComponent; diff --git a/ui/snippets/searchBar/SearchBar.tsx b/ui/snippets/searchBar/SearchBar.tsx index 418b675fbc..bc342668f7 100644 --- a/ui/snippets/searchBar/SearchBar.tsx +++ b/ui/snippets/searchBar/SearchBar.tsx @@ -19,7 +19,7 @@ import SearchBarBackdrop from './SearchBarBackdrop'; import SearchBarInput from './SearchBarInput'; import SearchBarRecentKeywords from './SearchBarRecentKeywords'; import SearchBarSuggest from './SearchBarSuggest/SearchBarSuggest'; -import useQuickSearchQuery from './useQuickSearchQuery'; +import useSearchWithClusters from './useSearchWithClusters'; type Props = { isHomepage?: boolean; @@ -38,7 +38,7 @@ const SearchBar = ({ isHomepage }: Props) => { const recentSearchKeywords = getRecentSearchKeywords(); - const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query } = useQuickSearchQuery(); + const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query } = useSearchWithClusters(); const handleSubmit = React.useCallback((event: FormEvent) => { event.preventDefault(); diff --git a/ui/snippets/searchBar/SearchBarInput.tsx b/ui/snippets/searchBar/SearchBarInput.tsx index 1def4ef784..3908d5f750 100644 --- a/ui/snippets/searchBar/SearchBarInput.tsx +++ b/ui/snippets/searchBar/SearchBarInput.tsx @@ -4,6 +4,7 @@ import { throttle } from 'es-toolkit'; import React from 'react'; import type { ChangeEvent, FormEvent, FocusEvent } from 'react'; +import config from 'configs/app'; import { useScrollDirection } from 'lib/contexts/scrollDirection'; import useIsMobile from 'lib/hooks/useIsMobile'; import { Input } from 'toolkit/chakra/input'; @@ -101,6 +102,15 @@ const SearchBarInput = ( const transformMobile = scrollDirection !== 'down' ? 'translateY(0)' : 'translateY(-100%)'; + const getPlaceholder = () => { + if (isMobile) { + return 'Search by address / ... '; + } + + const clusterText = config.features.clusters.isEnabled ? ' / cluster ' : ''; + return `Search by address / txn hash / block / token${ clusterText }/... `; + }; + const startElement = ( topLimit) { + if (categoriesRefs.current[i]?.getBoundingClientRect().y <= topLimit && categoriesRefs.current[i + 1]?.getBoundingClientRect().y > topLimit) { const currentCategory = categoriesRefs.current[i]; const currentCategoryId = currentCategory.getAttribute('data-id'); if (currentCategoryId) { diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx index 7050386370..189e9ed52a 100644 --- a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx @@ -7,7 +7,7 @@ import type { SearchResultAddressOrContract, SearchResultMetadataTag } from 'typ import { toBech32Address } from 'lib/address/bech32'; import dayjs from 'lib/date/dayjs'; import highlightText from 'lib/highlightText'; -import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address'; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import SearchResultEntityTag from 'ui/searchResults/SearchResultEntityTag'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCluster.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCluster.tsx new file mode 100644 index 0000000000..09ca35a3b3 --- /dev/null +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCluster.tsx @@ -0,0 +1,72 @@ +import { Grid, Text, Flex, Box } from '@chakra-ui/react'; +import React from 'react'; + +import type { ItemsProps } from './types'; +import type { SearchResultCluster } from 'types/api/search'; + +import { toBech32Address } from 'lib/address/bech32'; +import { isEvmAddress } from 'lib/address/isEvmAddress'; +import highlightText from 'lib/highlightText'; +import ClusterIcon from 'ui/shared/ClusterIcon'; +import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; + +const SearchBarSuggestCluster = ({ data, searchTerm, addressFormat }: ItemsProps) => { + const hash = data.filecoin_robust_address || (addressFormat === 'bech32' ? toBech32Address(data.address_hash) : data.address_hash); + const isClickable = isEvmAddress(data.address_hash); + + const shouldShowTrailingSlash = searchTerm.trim().endsWith('/'); + const displayName = shouldShowTrailingSlash ? data.cluster_info.name + '/' : data.cluster_info.name; + const searchTermForHighlight = searchTerm.replace(/\/$/, ''); + + const containerProps = { + opacity: isClickable ? 1 : 0.6, + }; + + const icon = ; + + const name = ( + + + + ); + + const address = ( + + + + ); + + return ( + + + + { icon } + { name } + + + { address } + + + + ); +}; + +export default React.memo(SearchBarSuggestCluster); diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx index 2ae22ae287..11031e2533 100644 --- a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx @@ -5,9 +5,12 @@ import type { AddressFormat } from 'types/views/address'; import { route } from 'nextjs-routes'; +import { isEvmAddress } from 'lib/address/isEvmAddress'; + import SearchBarSuggestAddress from './SearchBarSuggestAddress'; import SearchBarSuggestBlob from './SearchBarSuggestBlob'; import SearchBarSuggestBlock from './SearchBarSuggestBlock'; +import SearchBarSuggestCluster from './SearchBarSuggestCluster'; import SearchBarSuggestDomain from './SearchBarSuggestDomain'; import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; import SearchBarSuggestLabel from './SearchBarSuggestLabel'; @@ -25,7 +28,6 @@ interface Props { } const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressFormat }: Props) => { - const url = (() => { switch (data.type) { case 'token': { @@ -57,6 +59,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm case 'ens_domain': { return route({ pathname: '/address/[hash]', query: { hash: data.address_hash } }); } + case 'cluster': { + return route({ pathname: '/address/[hash]', query: { hash: data.address_hash } }); + } case 'tac_operation': { return route({ pathname: '/operation/[id]', query: { id: data.tac_operation.operation_id } }); } @@ -112,12 +117,21 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm case 'ens_domain': { return ; } + case 'cluster': { + return ; + } case 'tac_operation': { return ; } } })(); + const hasLink = data.type === 'cluster' ? isEvmAddress(data.address_hash) : true; + + if (!hasLink) { + return content; + } + return ( { content } diff --git a/ui/snippets/searchBar/useSearchWithClusters.test.tsx b/ui/snippets/searchBar/useSearchWithClusters.test.tsx new file mode 100644 index 0000000000..ed31d80f53 --- /dev/null +++ b/ui/snippets/searchBar/useSearchWithClusters.test.tsx @@ -0,0 +1,632 @@ +import { renderHook } from '@testing-library/react'; + +import useApiQuery from 'lib/api/useApiQuery'; + +import useQuickSearchQuery from './useQuickSearchQuery'; +import useSearchWithClusters from './useSearchWithClusters'; + +type MockQuickSearchQuery = ReturnType; +type MockApiQuery = ReturnType; + +jest.mock('lib/api/useApiQuery'); +jest.mock('./useQuickSearchQuery'); +jest.mock('lib/hooks/useDebounce', () => (value: unknown) => value); + +const mockUseApiQuery = useApiQuery as jest.MockedFunction; +const mockUseQuickSearchQuery = useQuickSearchQuery as jest.MockedFunction; + +jest.mock('configs/app', () => ({ + features: { + clusters: { + isEnabled: true, + }, + rollbar: { + isEnabled: false, + }, + }, +})); + +describe('useSearchWithClusters', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '', + debouncedSearchTerm: '', + handleSearchTermChange: jest.fn(), + query: { + data: [], + isError: false, + isLoading: false, + }, + redirectCheckQuery: { + data: null, + isError: false, + isLoading: false, + }, + } as unknown as MockQuickSearchQuery); + + mockUseApiQuery.mockReturnValue({ + data: null, + isError: false, + isLoading: false, + } as unknown as MockApiQuery); + }); + + describe('cluster search pattern matching', () => { + it('should detect cluster search with trailing slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster/', + debouncedSearchTerm: 'test-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'test-cluster' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'test-cluster' ], + enabled: true, + select: expect.any(Function), + }, + }); + }); + + it('should detect cluster search with slash in middle (no trailing slash)', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'campnetwork/lol', + debouncedSearchTerm: 'campnetwork/lol', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'campnetwork/lol' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'campnetwork/lol' ], + enabled: true, + select: expect.any(Function), + }, + }); + }); + + it('should not detect cluster search without any slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster', + debouncedSearchTerm: 'test-cluster', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: '' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', '' ], + enabled: false, + select: expect.any(Function), + }, + }); + }); + + it('should handle cluster search with whitespace and trailing slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: ' my-cluster/ ', + debouncedSearchTerm: ' my-cluster/ ', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'my-cluster' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'my-cluster' ], + enabled: true, + select: expect.any(Function), + }, + }); + }); + + it('should handle complex cluster names with hyphens and numbers', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster-123/', + debouncedSearchTerm: 'test-cluster-123/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'test-cluster-123' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'test-cluster-123' ], + enabled: true, + select: expect.any(Function), + }, + }); + }); + + it('should extract cluster name correctly from various formats', () => { + const testCases = [ + { input: 'simple/', expected: 'simple' }, + { input: 'cluster-name/', expected: 'cluster-name' }, + { input: 'my_cluster_123/', expected: 'my_cluster_123' }, + { input: 'ClusterWithCaps/', expected: 'ClusterWithCaps' }, + { input: 'campnetwork/lol', expected: 'campnetwork/lol' }, + { input: 'path/to/cluster/', expected: 'path/to/cluster' }, + { input: ' spaced/cluster/ ', expected: 'spaced/cluster' }, + ]; + + testCases.forEach(({ input, expected }) => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: input, + debouncedSearchTerm: input, + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: expected }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', expected ], + enabled: true, + select: expect.any(Function), + }, + }); + + jest.clearAllMocks(); + }); + }); + + it('should detect cluster search with multiple slashes', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'org/team/project', + debouncedSearchTerm: 'org/team/project', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'org/team/project' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'org/team/project' ], + enabled: true, + select: expect.any(Function), + }, + }); + }); + + it('should handle the reported issue: campnetwork/lol with and without trailing slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'campnetwork/lol/', + debouncedSearchTerm: 'campnetwork/lol/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'campnetwork/lol' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'campnetwork/lol' ], + enabled: true, + select: expect.any(Function), + }, + }); + + jest.clearAllMocks(); + + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'campnetwork/lol', + debouncedSearchTerm: 'campnetwork/lol', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'campnetwork/lol' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'campnetwork/lol' ], + enabled: true, + select: expect.any(Function), + }, + }); + }); + }); + + describe('data transformation', () => { + it('should transform cluster API response to search result format', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster/', + debouncedSearchTerm: 'test-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const transformedData = [ + { + type: 'cluster', + name: 'test-cluster', + address_hash: '0x1234567890123456789012345678901234567890', + is_smart_contract_verified: false, + cluster_info: { + cluster_id: 'cluster-123', + name: 'test-cluster', + owner: '0x1234567890123456789012345678901234567890', + created_at: '2024-01-01T00:00:00Z', + expires_at: '2025-01-01T00:00:00Z', + total_wei_amount: '1000000000000000000', + is_testnet: false, + }, + }, + ]; + + mockUseApiQuery.mockReturnValue({ + data: transformedData, + isError: false, + isLoading: false, + } as unknown as MockApiQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual(transformedData); + }); + + it('should handle cluster data without optional fields', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'simple-cluster/', + debouncedSearchTerm: 'simple-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const transformedData = [ + { + type: 'cluster', + name: 'simple-cluster', + address_hash: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + is_smart_contract_verified: false, + cluster_info: { + cluster_id: 'simple-cluster', + name: 'simple-cluster', + owner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + created_at: undefined, + expires_at: undefined, + total_wei_amount: undefined, + is_testnet: undefined, + }, + }, + ]; + + mockUseApiQuery.mockReturnValue({ + data: transformedData, + isError: false, + isLoading: false, + } as unknown as MockApiQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual(transformedData); + }); + + it('should use clusterId as fallback when present', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test/', + debouncedSearchTerm: 'test/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const transformedData = [ + { + type: 'cluster', + name: 'test', + address_hash: '0x123', + is_smart_contract_verified: false, + cluster_info: { + cluster_id: 'test', + name: 'test', + owner: '0x123', + created_at: undefined, + expires_at: undefined, + total_wei_amount: undefined, + is_testnet: undefined, + }, + }, + ]; + + mockUseApiQuery.mockReturnValue({ + data: transformedData, + isError: false, + isLoading: false, + } as unknown as MockApiQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toBeDefined(); + expect(result.current.query.data).toHaveLength(1); + const clusterResult = result.current.query.data![0] as unknown as Record; + expect((clusterResult.cluster_info as Record).cluster_id).toBe('test'); + }); + + it('should return empty results when cluster API returns error', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'nonexistent-cluster/', + debouncedSearchTerm: 'nonexistent-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + mockUseApiQuery.mockReturnValue({ + data: [], + isError: true, + isLoading: false, + } as unknown as MockApiQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual([]); + expect(result.current.query.isError).toBe(false); + }); + + it('should return empty results when cluster API returns no data', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'empty-cluster/', + debouncedSearchTerm: 'empty-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + mockUseApiQuery.mockReturnValue({ + data: [], + isError: false, + isLoading: false, + } as unknown as MockApiQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual([]); + }); + }); + + describe('fallback to regular search', () => { + it('should return regular search results for non-cluster queries', () => { + const regularSearchData = [ + { type: 'address', address_hash: '0x123', is_smart_contract_verified: true }, + ]; + + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '0x123456', + debouncedSearchTerm: '0x123456', + handleSearchTermChange: jest.fn(), + query: { + data: regularSearchData, + isError: false, + isLoading: false, + }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual(regularSearchData); + }); + + it('should preserve regular search query properties', () => { + const mockQuickSearchQuery = { + searchTerm: 'regular search', + debouncedSearchTerm: 'regular search', + handleSearchTermChange: jest.fn(), + query: { + data: [], + isError: false, + isLoading: true, + isFetching: true, + }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + }; + + mockUseQuickSearchQuery.mockReturnValue(mockQuickSearchQuery as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.isLoading).toBe(true); + expect(result.current.query.isFetching).toBe(true); + expect(result.current.searchTerm).toBe('regular search'); + expect(result.current.debouncedSearchTerm).toBe('regular search'); + }); + + it('should preserve error states from regular search', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'error-search', + debouncedSearchTerm: 'error-search', + handleSearchTermChange: jest.fn(), + query: { + data: [], + isError: true, + error: 'Network error', + isLoading: false, + }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.isError).toBe(true); + expect(result.current.query.error).toBe('Network error'); + }); + }); + + describe('integration behavior', () => { + it('should enable cluster API query only for valid cluster searches', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '', + debouncedSearchTerm: '', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: '' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', '' ], + enabled: false, + select: expect.any(Function), + }, + }); + }); + + it('should not query cluster API when cluster name is empty', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '/', + debouncedSearchTerm: '/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: '' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', '' ], + enabled: false, + select: expect.any(Function), + }, + }); + }); + + it('should return proper hook interface', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test', + debouncedSearchTerm: 'test-debounced', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: 'redirect-data', isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current).toHaveProperty('searchTerm', 'test'); + expect(result.current).toHaveProperty('debouncedSearchTerm', 'test-debounced'); + expect(result.current).toHaveProperty('handleSearchTermChange'); + expect(result.current).toHaveProperty('query'); + expect(result.current.redirectCheckQuery).toEqual({ data: 'redirect-data', isError: false, isLoading: false }); + }); + + it('should handle loading states correctly', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'loading-cluster/', + debouncedSearchTerm: 'loading-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + mockUseApiQuery.mockReturnValue({ + data: [], + isError: false, + isLoading: true, + } as unknown as MockApiQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.isLoading).toBe(true); + }); + }); + + describe('debouncing integration', () => { + it('should use debounced search term for cluster detection', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'original-term/', + debouncedSearchTerm: 'final-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: 'final-cluster' }), + }, + queryOptions: { + queryKey: [ 'clusters:get_cluster_by_name', 'search', 'final-cluster' ], + enabled: true, + select: expect.any(Function), + }, + }); + }); + + it('should pass through original search term for display', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'original-term', + debouncedSearchTerm: 'debounced/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.searchTerm).toBe('original-term'); + expect(result.current.debouncedSearchTerm).toBe('debounced/'); + }); + }); +}); diff --git a/ui/snippets/searchBar/useSearchWithClusters.tsx b/ui/snippets/searchBar/useSearchWithClusters.tsx new file mode 100644 index 0000000000..0a063c651c --- /dev/null +++ b/ui/snippets/searchBar/useSearchWithClusters.tsx @@ -0,0 +1,105 @@ +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import type { SearchResultCluster } from 'types/api/search'; + +import config from 'configs/app'; +import type { ResourcePayload, ResourceError } from 'lib/api/resources'; +import useApiFetch from 'lib/api/useApiFetch'; +import { getResourceKey } from 'lib/api/useApiQuery'; + +import useQuickSearchQuery from './useQuickSearchQuery'; + +function isClusterSearch(term: string): boolean { + const trimmed = term.trim(); + const hasTrailingSlash = trimmed.endsWith('/'); + const looksLikeCluster = trimmed.includes('/') || hasTrailingSlash; + + return looksLikeCluster; +} + +function extractClusterName(term: string): string { + const trimmed = term.trim(); + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; +} + +function transformClusterToSearchResult(cluster: { + name: string; + clusterId?: string; + owner: string; + createdAt?: string; + expiresAt?: string | null; + backingWei?: string; + isTestnet?: boolean; +}, ownerAddress: string): SearchResultCluster { + return { + type: 'cluster', + name: cluster.name, + address_hash: ownerAddress, + is_smart_contract_verified: false, + cluster_info: { + cluster_id: cluster.clusterId || cluster.name, + name: cluster.name, + owner: cluster.owner, + created_at: cluster.createdAt, + expires_at: cluster.expiresAt, + total_wei_amount: cluster.backingWei, + is_testnet: cluster.isTestnet, + }, + }; +} + +export default function useSearchWithClusters() { + const quickSearch = useQuickSearchQuery(); + + const isClusterQuery = config.features.clusters.isEnabled ? + isClusterSearch(quickSearch.debouncedSearchTerm) : false; + + const clusterName = isClusterQuery ? extractClusterName(quickSearch.debouncedSearchTerm) : ''; + + const RESOURCE_NAME = 'clusters:get_cluster_by_name'; + type ClusterQueryResult = ResourcePayload; + + const apiFetch = useApiFetch(); + + const clusterQuery = useQuery, Array>({ + queryKey: getResourceKey(RESOURCE_NAME, { queryParams: { input: clusterName } }), + queryFn: async({ signal }) => { + try { + const result = await apiFetch(RESOURCE_NAME, { + queryParams: { input: JSON.stringify({ name: clusterName }) }, + fetchParams: { signal }, + }) as ClusterQueryResult; + return result; + } catch (error) { + return null; + } + }, + enabled: config.features.clusters.isEnabled && isClusterQuery && clusterName.length > 0, + select: (data) => { + if (!data?.result?.data) return []; + return [ transformClusterToSearchResult(data.result.data, data.result.data.owner) ]; + }, + }); + + const combinedQuery = React.useMemo(() => { + if (!config.features.clusters.isEnabled || !isClusterQuery) { + return quickSearch.query; + } + + return clusterQuery; + }, [ isClusterQuery, quickSearch, clusterQuery ]); + + const result = React.useMemo(() => ({ + searchTerm: quickSearch.searchTerm, + debouncedSearchTerm: quickSearch.debouncedSearchTerm, + handleSearchTermChange: quickSearch.handleSearchTermChange, + query: combinedQuery, + redirectCheckQuery: quickSearch.redirectCheckQuery, + }), [ + quickSearch, + combinedQuery, + ]); + + return config.features.clusters.isEnabled ? result : quickSearch; +}