Commit 528ee6fa authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #1809 from blockscout/fe-1768

Support ens_domain search result type
parents d7e984b4 dea727aa
...@@ -59,6 +59,7 @@ NEXT_PUBLIC_HAS_USER_OPS=true ...@@ -59,6 +59,7 @@ NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_AD_BANNER_PROVIDER=getit NEXT_PUBLIC_AD_BANNER_PROVIDER=getit
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED=true NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED=true
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.blockscout.com
#meta #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/sepolia-testnet.png NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/sepolia-testnet.png
...@@ -7,6 +7,7 @@ import type { ...@@ -7,6 +7,7 @@ import type {
SearchResult, SearchResult,
SearchResultUserOp, SearchResultUserOp,
SearchResultBlob, SearchResultBlob,
SearchResultDomain,
} from 'types/api/search'; } from 'types/api/search';
export const token1: SearchResultToken = { export const token1: SearchResultToken = {
...@@ -123,6 +124,20 @@ export const blob1: SearchResultBlob = { ...@@ -123,6 +124,20 @@ export const blob1: SearchResultBlob = {
timestamp: null, timestamp: null,
}; };
export const domain1: SearchResultDomain = {
address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
ens_info: {
address_hash: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
expiry_date: '2039-09-01T07:36:18.000Z',
name: 'vitalik.eth',
names_count: 1,
},
is_smart_contract_verified: false,
name: null,
type: 'ens_domain',
url: '/address/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
};
export const baseResponse: SearchResult = { export const baseResponse: SearchResult = {
items: [ items: [
token1, token1,
...@@ -132,6 +147,7 @@ export const baseResponse: SearchResult = { ...@@ -132,6 +147,7 @@ export const baseResponse: SearchResult = {
contract1, contract1,
tx1, tx1,
blob1, blob1,
domain1,
], ],
next_page_params: null, next_page_params: null,
}; };
...@@ -31,6 +31,20 @@ export interface SearchResultAddressOrContract { ...@@ -31,6 +31,20 @@ export interface SearchResultAddressOrContract {
}; };
} }
export interface SearchResultDomain {
type: 'ens_domain';
name: string | null;
address: string;
is_smart_contract_verified: boolean;
url?: string; // not used by the frontend, we build the url ourselves
ens_info: {
address_hash: string;
expiry_date?: string;
name: string;
names_count: number;
};
}
export interface SearchResultLabel { export interface SearchResultLabel {
type: 'label'; type: 'label';
address: string; address: string;
...@@ -69,7 +83,7 @@ export interface SearchResultUserOp { ...@@ -69,7 +83,7 @@ export interface SearchResultUserOp {
} }
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp | export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp |
SearchResultBlob; SearchResultBlob | SearchResultDomain;
export interface SearchResult { export interface SearchResult {
items: Array<SearchResultItem>; items: Array<SearchResultItem>;
......
...@@ -183,6 +183,31 @@ test('search by blob hash +@mobile', async({ mount, page }) => { ...@@ -183,6 +183,31 @@ test('search by blob hash +@mobile', async({ mount, page }) => {
await expect(component.locator('main')).toHaveScreenshot(); await expect(component.locator('main')).toHaveScreenshot();
}); });
test('search by domain name +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.domain1.ens_info.name },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.domain1.ens_info.name }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.domain1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
const testWithUserOps = test.extend({ const testWithUserOps = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any, context: contextWithEnvs(configs.featureEnvs.userOps) as any,
......
...@@ -14,6 +14,7 @@ import { ADDRESS_REGEXP } from 'lib/validations/address'; ...@@ -14,6 +14,7 @@ import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
...@@ -243,6 +244,30 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -243,6 +244,30 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</UserOpEntity.Container> </UserOpEntity.Container>
); );
} }
case 'ens_domain': {
return (
<EnsEntity.Container>
<EnsEntity.Icon/>
<LinkInternal
href={ route({ pathname: '/address/[hash]', query: { hash: data.address } }) }
fontWeight={ 700 }
wordBreak="break-all"
isLoading={ isLoading }
onClick={ handleLinkClick }
overflow="hidden"
>
<Skeleton
isLoaded={ !isLoading }
dangerouslySetInnerHTML={{ __html: highlightText(data.ens_info.name, searchTerm) }}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
/>
</LinkInternal>
</EnsEntity.Container>
);
}
} }
})(); })();
...@@ -334,6 +359,21 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -334,6 +359,21 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
) : ) :
null; null;
} }
case 'ens_domain': {
const expiresText = data.ens_info?.expiry_date ? ` expires ${ dayjs(data.ens_info.expiry_date).fromNow() }` : '';
return (
<Flex alignItems="center" gap={ 3 }>
<Box overflow="hidden">
<HashStringShortenDynamic hash={ data.address }/>
</Box>
{
data.ens_info.names_count > 1 ?
<chakra.span color="text_secondary"> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</chakra.span> :
<chakra.span color="text_secondary">{ expiresText }</chakra.span>
}
</Flex>
);
}
default: default:
return null; return null;
......
...@@ -14,6 +14,7 @@ import { ADDRESS_REGEXP } from 'lib/validations/address'; ...@@ -14,6 +14,7 @@ import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
...@@ -337,6 +338,48 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -337,6 +338,48 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</> </>
); );
} }
case 'ens_domain': {
const expiresText = data.ens_info?.expiry_date ? ` expires ${ dayjs(data.ens_info.expiry_date).fromNow() }` : '';
return (
<>
<Td fontSize="sm">
<EnsEntity.Container>
<EnsEntity.Icon/>
<LinkInternal
href={ route({ pathname: '/address/[hash]', query: { hash: data.address } }) }
fontWeight={ 700 }
wordBreak="break-all"
isLoading={ isLoading }
onClick={ handleLinkClick }
overflow="hidden"
>
<Skeleton
isLoaded={ !isLoading }
dangerouslySetInnerHTML={{ __html: highlightText(data.ens_info.name, searchTerm) }}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
/>
</LinkInternal>
</EnsEntity.Container>
</Td>
<Td>
<Flex alignItems="center" overflow="hidden">
<Box overflow="hidden" whiteSpace="nowrap" w={ data.is_smart_contract_verified ? 'calc(100%-28px)' : 'unset' }>
<HashStringShortenDynamic hash={ data.address }/>
</Box>
{ data.is_smart_contract_verified && <IconSvg name="status/success" boxSize="14px" color="green.500" ml={ 1 } flexShrink={ 0 }/> }
</Flex>
</Td>
<Td>
{ data.ens_info.names_count > 1 ?
<chakra.span color="text_secondary"> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</chakra.span> :
<chakra.span color="text_secondary">{ expiresText }</chakra.span> }
</Td>
</>
);
}
} }
})(); })();
......
...@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace'; ...@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace';
import config from 'configs/app'; import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob'; export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain';
export type Category = ApiCategory | 'app'; export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap = export type ItemsCategoriesMap =
...@@ -17,6 +17,7 @@ export type SearchResultAppItem = { ...@@ -17,6 +17,7 @@ export type SearchResultAppItem = {
export const searchCategories: Array<{id: Category; title: string }> = [ export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'app', title: 'DApps' }, { id: 'app', title: 'DApps' },
{ id: 'domain', title: 'Names' },
{ id: 'token', title: 'Tokens (ERC-20)' }, { id: 'token', title: 'Tokens (ERC-20)' },
{ id: 'nft', title: 'NFTs (ERC-721 & 1155)' }, { id: 'nft', title: 'NFTs (ERC-721 & 1155)' },
{ id: 'address', title: 'Addresses' }, { id: 'address', title: 'Addresses' },
...@@ -32,6 +33,7 @@ if (config.features.userOps.isEnabled) { ...@@ -32,6 +33,7 @@ if (config.features.userOps.isEnabled) {
export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = { export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = {
app: { itemTitle: 'DApp', itemTitleShort: 'App' }, app: { itemTitle: 'DApp', itemTitleShort: 'App' },
domain: { itemTitle: 'Name', itemTitleShort: 'Name' },
token: { itemTitle: 'Token', itemTitleShort: 'Token' }, token: { itemTitle: 'Token', itemTitleShort: 'Token' },
nft: { itemTitle: 'NFT', itemTitleShort: 'NFT' }, nft: { itemTitle: 'NFT', itemTitleShort: 'NFT' },
address: { itemTitle: 'Address', itemTitleShort: 'Address' }, address: { itemTitle: 'Address', itemTitleShort: 'Address' },
...@@ -72,5 +74,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C ...@@ -72,5 +74,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'blob': { case 'blob': {
return 'blob'; return 'blob';
} }
case 'ens_domain': {
return 'domain';
}
} }
} }
...@@ -225,6 +225,26 @@ test('search by blob hash +@mobile', async({ mount, page }) => { ...@@ -225,6 +225,26 @@ test('search by blob hash +@mobile', async({ mount, page }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
}); });
test('search by domain name +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.domain1.ens_info.name }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
searchMock.domain1,
]),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).fill(searchMock.domain1.ens_info.name);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
const testWithUserOps = base.extend({ const testWithUserOps = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any, context: contextWithEnvs(configs.featureEnvs.userOps) as any,
......
import { Grid, Text, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultDomain } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: SearchResultDomain;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestDomain = ({ data, isMobile, searchTerm }: Props) => {
const icon = <IconSvg name="ENS_slim" boxSize={ 5 } color="gray.500"/>;
const name = (
<Text
fontWeight={ 700 }
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.ens_info.name, searchTerm) }}/>
</Text>
);
const address = (
<Text
overflow="hidden"
whiteSpace="nowrap"
variant="secondary"
>
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Text>
);
const isContractVerified = data.is_smart_contract_verified && <IconSvg name="status/success" boxSize="14px" color="green.500" flexShrink={ 0 }/>;
const expiresText = data.ens_info?.expiry_date ? ` expires ${ dayjs(data.ens_info.expiry_date).fromNow() }` : '';
const ensNamesCount = data?.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }`;
const additionalInfo = (
<Text variant="secondary" textAlign={ isMobile ? 'start' : 'end' }>
{ data?.ens_info.names_count > 1 ? ensNamesCount : expiresText }
</Text>
);
if (isMobile) {
return (
<>
<Flex alignItems="center" overflow="hidden" gap={ 2 }>
{ icon }
{ name }
</Flex>
<Flex alignItems="center" overflow="hidden" gap={ 1 }>
{ address }
{ isContractVerified }
</Flex>
{ additionalInfo }
</>
);
}
return (
<Grid alignItems="center" gridTemplateColumns="228px minmax(auto, max-content) auto" gap={ 2 }>
<Flex alignItems="center" gap={ 2 }>
{ icon }
{ name }
</Flex>
<Flex alignItems="center" overflow="hidden" gap={ 1 }>
{ address }
{ isContractVerified }
</Flex>
{ additionalInfo }
</Grid>
);
};
export default React.memo(SearchBarSuggestDomain);
...@@ -9,6 +9,7 @@ import { route } from 'nextjs-routes'; ...@@ -9,6 +9,7 @@ import { route } from 'nextjs-routes';
import SearchBarSuggestAddress from './SearchBarSuggestAddress'; import SearchBarSuggestAddress from './SearchBarSuggestAddress';
import SearchBarSuggestBlob from './SearchBarSuggestBlob'; import SearchBarSuggestBlob from './SearchBarSuggestBlob';
import SearchBarSuggestBlock from './SearchBarSuggestBlock'; import SearchBarSuggestBlock from './SearchBarSuggestBlock';
import SearchBarSuggestDomain from './SearchBarSuggestDomain';
import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel'; import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestToken from './SearchBarSuggestToken'; import SearchBarSuggestToken from './SearchBarSuggestToken';
...@@ -46,6 +47,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -46,6 +47,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'blob': { case 'blob': {
return route({ pathname: '/blobs/[hash]', query: { hash: data.blob_hash } }); return route({ pathname: '/blobs/[hash]', query: { hash: data.blob_hash } });
} }
case 'ens_domain': {
return route({ pathname: '/address/[hash]', query: { hash: data.address } });
}
} }
})(); })();
...@@ -74,6 +78,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -74,6 +78,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'blob': { case 'blob': {
return <SearchBarSuggestBlob data={ data } searchTerm={ searchTerm }/>; return <SearchBarSuggestBlob data={ data } searchTerm={ searchTerm }/>;
} }
case 'ens_domain': {
return <SearchBarSuggestDomain data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
} }
})(); })();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment