Commit c8d6980b authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Support metadata_tag type in search (#2559)

* Support metadata_tag type in search

* Update ui/searchResults/SearchResultListItem.tsx
Co-authored-by: default avatartom goriunov <tom@ohhhh.me>

---------
Co-authored-by: default avatartom goriunov <tom@ohhhh.me>
parent bcd5c2b6
......@@ -8,6 +8,7 @@ import type {
SearchResultUserOp,
SearchResultBlob,
SearchResultDomain,
SearchResultMetadataTag,
} from 'types/api/search';
export const token1: SearchResultToken = {
......@@ -147,6 +148,42 @@ export const domain1: SearchResultDomain = {
url: '/address/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
};
export const metatag1: SearchResultMetadataTag = {
...address1,
type: 'metadata_tag',
metadata: {
name: 'utko',
slug: 'utko',
meta: {},
tagType: 'name',
ordinal: 1,
},
};
export const metatag2: SearchResultMetadataTag = {
...address2,
type: 'metadata_tag',
metadata: {
name: 'utko',
slug: 'utko',
meta: {},
tagType: 'name',
ordinal: 1,
},
};
export const metatag3: SearchResultMetadataTag = {
...contract2,
type: 'metadata_tag',
metadata: {
name: 'super utko',
slug: 'super-utko',
meta: {},
tagType: 'protocol',
ordinal: 1,
},
};
export const baseResponse: SearchResult = {
items: [
token1,
......@@ -157,6 +194,8 @@ export const baseResponse: SearchResult = {
tx1,
blob1,
domain1,
metatag1,
],
next_page_params: null,
};
import type * as bens from '@blockscout/bens-types';
import type { TokenType } from 'types/api/token';
export type SearchResultType = 'token' | 'address' | 'block' | 'transaction' | 'contract';
import type { AddressMetadataTagApi } from './addressMetadata';
export const SEARCH_RESULT_TYPES = {
token: 'token',
address: 'address',
block: 'block',
transaction: 'transaction',
contract: 'contract',
ens_domain: 'ens_domain',
label: 'label',
user_operation: 'user_operation',
blob: 'blob',
metadata_tag: 'metadata_tag',
} as const;
export type SearchResultType = typeof SEARCH_RESULT_TYPES[keyof typeof SEARCH_RESULT_TYPES];
export interface SearchResultToken {
type: 'token';
......@@ -20,29 +35,35 @@ export interface SearchResultToken {
certified?: boolean;
}
export interface SearchResultAddressOrContract {
type: 'address' | 'contract';
name: string | null;
address: string;
is_smart_contract_verified: boolean;
certified?: true;
filecoin_robust_address?: string | null;
url?: string; // not used by the frontend, we build the url ourselves
ens_info?: {
type SearchResultEnsInfo = {
address_hash: string;
expiry_date?: string;
name: string;
names_count: number;
};
}
} | null;
export interface SearchResultDomain {
type: 'ens_domain';
interface SearchResultAddressData {
name: string | null;
address: string;
filecoin_robust_address?: string | null;
is_smart_contract_verified: boolean;
certified?: true;
filecoin_robust_address?: string | null;
url?: string; // not used by the frontend, we build the url ourselves
}
export interface SearchResultAddressOrContract extends SearchResultAddressData {
type: 'address' | 'contract';
ens_info?: SearchResultEnsInfo;
}
export interface SearchResultMetadataTag extends SearchResultAddressData {
type: 'metadata_tag';
ens_info?: SearchResultEnsInfo;
metadata: AddressMetadataTagApi;
}
export interface SearchResultDomain extends SearchResultAddressData {
type: 'ens_domain';
ens_info: {
address_hash: string;
expiry_date?: string;
......@@ -90,8 +111,16 @@ export interface SearchResultUserOp {
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp |
SearchResultBlob | SearchResultDomain;
export type SearchResultItem =
SearchResultToken |
SearchResultAddressOrContract |
SearchResultBlock |
SearchResultTx |
SearchResultLabel |
SearchResultUserOp |
SearchResultBlob |
SearchResultDomain |
SearchResultMetadataTag;
export interface SearchResult {
items: Array<SearchResultItem>;
......
......@@ -20,6 +20,7 @@ test.describe('search by name', () => {
searchMock.token2,
searchMock.contract1,
searchMock.address2,
searchMock.metatag1,
searchMock.label1,
],
next_page_params: null,
......@@ -52,6 +53,23 @@ test('search by address hash +@mobile', async({ render, mockApiResponse }) => {
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by meta tag +@mobile', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { q: 'utko' },
},
};
const data = {
items: [ searchMock.metatag1, searchMock.metatag2, searchMock.metatag3 ],
next_page_params: null,
};
await mockApiResponse('search', data, { queryParams: { q: 'utko' } });
const component = await render(<SearchResults/>, { hooksConfig });
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by block number +@mobile', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
......
......@@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import type { FormEvent } from 'react';
import React from 'react';
import { SEARCH_RESULT_TYPES } from 'types/api/search';
import type { SearchResultItem } from 'types/client/search';
import config from 'configs/app';
......@@ -95,6 +96,9 @@ const SearchResultsPageContent = () => {
const displayedItems: Array<SearchResultItem | SearchResultAppItem> = React.useMemo(() => {
const apiData = (data?.items || []).filter((item) => {
if (!SEARCH_RESULT_TYPES[item.type]) {
return false;
}
if (!config.features.userOps.isEnabled && item.type === 'user_operation') {
return false;
}
......
......@@ -21,6 +21,7 @@ import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import EntityTagIcon from 'ui/shared/EntityTags/EntityTagIcon';
import { ADDRESS_REGEXP } from 'ui/shared/forms/validators/address';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
......@@ -79,6 +80,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr
);
}
case 'metadata_tag':
case 'contract':
case 'address': {
const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
......@@ -357,13 +359,16 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr
</Text>
);
}
case 'metadata_tag':
case 'contract':
case 'address': {
const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
const addressName = data.name || data.ens_info?.name;
const expiresText = data.ens_info?.expiry_date ? ` (expires ${ dayjs(data.ens_info.expiry_date).fromNow() })` : '';
return addressName ? (
return (addressName || data.type === 'metadata_tag') ? (
<Flex alignItems="center" gap={ 2 } justifyContent="space-between" flexWrap="wrap">
{ addressName && (
<Flex alignItems="center">
<Text
overflow="hidden"
......@@ -379,6 +384,15 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr
</Text>
{ data.certified && <ContractCertifiedLabel iconSize={ 4 } boxSize={ 4 } ml={ 1 }/> }
</Flex>
) }
{ data.type === 'metadata_tag' && (
// we show regular tag because we don't need all meta info here, but need to highlight search term
<Tag display="flex" alignItems="center">
<EntityTagIcon data={ data.metadata }/>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.metadata.name, searchTerm) }}/>
</Tag>
) }
</Flex>
) :
null;
}
......
......@@ -21,6 +21,7 @@ import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import EntityTagIcon from 'ui/shared/EntityTags/EntityTagIcon';
import { ADDRESS_REGEXP } from 'ui/shared/forms/validators/address';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
......@@ -99,6 +100,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P
);
}
case 'metadata_tag':
case 'contract':
case 'address': {
const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
......@@ -119,7 +121,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P
return (
<>
<Td fontSize="sm" colSpan={ addressName ? 1 : 3 }>
<Td fontSize="sm" colSpan={ (addressName || data.type === 'metadata_tag') ? 1 : 3 } verticalAlign="middle">
<AddressEntity.Container>
<AddressEntity.Icon address={ address }/>
<AddressEntity.Link
......@@ -138,7 +140,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P
</AddressEntity.Container>
</Td>
{ addressName && (
<Td colSpan={ 2 } fontSize="sm" verticalAlign="middle">
<Td colSpan={ data.type === 'metadata_tag' ? 1 : 2 } fontSize="sm" verticalAlign="middle">
<Flex alignItems="center">
<Text
overflow="hidden"
......@@ -159,6 +161,17 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P
</Flex>
</Td>
) }
{ data.type === 'metadata_tag' && (
<Td colSpan={ addressName ? 1 : 2 } fontSize="sm" verticalAlign="middle">
<Flex justifyContent="flex-end">
{ /* we show regular tag because we don't need all meta info here, but need to highlight search term */ }
<Tag display="flex" alignItems="center">
<EntityTagIcon data={ data.metadata } iconColor="gray.400"/>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.metadata.name, searchTerm) }}/>
</Tag>
</Flex>
</Td>
) }
</>
);
}
......
import type { ResponsiveValue } from '@chakra-ui/react';
import { chakra, Image, Tag } from '@chakra-ui/react';
import { Tag } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import Skeleton from 'ui/shared/chakra/Skeleton';
import IconSvg from 'ui/shared/IconSvg';
import TruncatedValue from 'ui/shared/TruncatedValue';
import EntityTagIcon from './EntityTagIcon';
import EntityTagLink from './EntityTagLink';
import EntityTagPopover from './EntityTagPopover';
import { getTagLinkParams } from './utils';
......@@ -26,7 +26,6 @@ const EntityTag = ({ data, isLoading, maxW, noLink }: Props) => {
}
const hasLink = !noLink && Boolean(getTagLinkParams(data));
const iconColor = data.meta?.textColor ?? 'gray.400';
const name = (() => {
if (data.meta?.warpcastHandle) {
......@@ -36,22 +35,6 @@ const EntityTag = ({ data, isLoading, maxW, noLink }: Props) => {
return data.name;
})();
const icon = (() => {
if (data.meta?.tagIcon) {
return <Image boxSize={ 3 } mr={ 1 } flexShrink={ 0 } src={ data.meta.tagIcon } alt={ `${ data.name } icon` }/>;
}
if (data.tagType === 'name') {
return <IconSvg name="publictags_slim" boxSize={ 3 } mr={ 1 } flexShrink={ 0 } color={ iconColor }/>;
}
if (data.tagType === 'protocol' || data.tagType === 'generic') {
return <chakra.span color={ iconColor } whiteSpace="pre"># </chakra.span>;
}
return null;
})();
return (
<EntityTagPopover data={ data }>
<Tag
......@@ -66,7 +49,7 @@ const EntityTag = ({ data, isLoading, maxW, noLink }: Props) => {
_hover={ hasLink ? { opacity: 0.76 } : undefined }
>
<EntityTagLink data={ data } noLink={ noLink }>
{ icon }
<EntityTagIcon data={ data } iconColor={ data.meta?.textColor }/>
<TruncatedValue value={ name } tooltipPlacement="top"/>
</EntityTagLink>
</Tag>
......
import { chakra, Image } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: TEntityTag;
iconColor?: string;
}
const EntityTagIcon = ({ data, iconColor = 'gray.400' }: Props) => {
if (data.meta?.tagIcon) {
return <Image boxSize={ 3 } mr={ 1 } flexShrink={ 0 } src={ data.meta.tagIcon } alt={ `${ data.name } icon` }/>;
}
if (data.tagType === 'name') {
return <IconSvg name="publictags_slim" boxSize={ 3 } mr={ 1 } flexShrink={ 0 } color={ iconColor }/>;
}
if (data.tagType === 'protocol' || data.tagType === 'generic') {
return <chakra.span color={ iconColor } whiteSpace="pre"># </chakra.span>;
}
return null;
};
export default React.memo(EntityTagIcon);
......@@ -53,7 +53,8 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
switch (item.type) {
case 'address':
case 'contract': {
case 'contract':
case 'metadata_tag': {
return 'address';
}
case 'token': {
......
......@@ -76,6 +76,19 @@ test('search by address hash +@mobile', async({ render, page, mockApiResponse })
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by meta tag +@mobile +@dark-mode', async({ render, page, mockApiResponse }) => {
const apiUrl = await mockApiResponse('quick_search', [
searchMock.metatag1,
searchMock.metatag2,
searchMock.metatag3,
], { queryParams: { q: 'utko' } });
await render(<SearchBar/>);
await page.getByPlaceholder(/search/i).fill('utko');
await page.waitForResponse(apiUrl);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
});
test('search by block number +@mobile', async({ render, page, mockApiResponse }) => {
const apiUrl = await mockApiResponse('quick_search', [
searchMock.block1,
......
......@@ -142,7 +142,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
return (
<>
{ resultCategories.length > 1 && (
<Box position="sticky" top="0" width="100%" background={ bgColor } py={ 5 } my={ -5 } ref={ tabsRef }>
<Box position="sticky" top="0" width="100%" background={ bgColor } py={ 5 } my={ -5 } ref={ tabsRef } zIndex={ 1 }>
<Tabs variant="outline" colorScheme="gray" size="sm" index={ tabIndex }>
<TabList columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
{ resultCategories.map((cat, index) => (
......
import { chakra, Box, Text, Flex } from '@chakra-ui/react';
import { chakra, Box, Text, Flex, Tag, Grid } from '@chakra-ui/react';
import React from 'react';
import type { ItemsProps } from './types';
import type { SearchResultAddressOrContract } from 'types/api/search';
import type { SearchResultAddressOrContract, SearchResultMetadataTag } from 'types/api/search';
import { toBech32Address } from 'lib/address/bech32';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EntityTagIcon from 'ui/shared/EntityTags/EntityTagIcon';
import { ADDRESS_REGEXP } from 'ui/shared/forms/validators/address';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
const SearchBarSuggestAddress = ({ data, isMobile, searchTerm, addressFormat }: ItemsProps<SearchResultAddressOrContract>) => {
type Props = ItemsProps<SearchResultAddressOrContract | SearchResultMetadataTag>;
const SearchBarSuggestAddress = ({ data, isMobile, searchTerm, addressFormat }: Props) => {
const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
const hash = data.filecoin_robust_address || (addressFormat === 'bech32' ? toBech32Address(data.address) : data.address);
......@@ -49,6 +52,13 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm, addressFormat }:
{ data.certified && <ContractCertifiedLabel boxSize={ 4 } iconSize={ 4 } ml={ 1 }/> }
</Flex>
);
const tagEl = data.type === 'metadata_tag' ? (
// we show regular tag because we don't need all meta info here, but need to highlight search term
<Tag display="flex" alignItems="center" ml={{ base: 0, lg: 'auto' }}>
<EntityTagIcon data={ data.metadata } iconColor="gray.400"/>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.metadata.name, searchTerm) }}/>
</Tag>
) : null;
const addressEl = <HashStringShortenDynamic hash={ hash } isTooltipDisabled/>;
if (isMobile) {
......@@ -66,14 +76,17 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm, addressFormat }:
{ addressEl }
</Box>
</Flex>
<Flex alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={ 2 }>
{ nameEl }
{ tagEl }
</Flex>
</>
);
}
return (
<Flex alignItems="center">
<Flex alignItems="center" w="450px" mr={ 2 }>
<Grid templateColumns="repeat(2, minmax(0, 1fr))" gap={ 2 }>
<Flex alignItems="center" mr={ 2 } minWidth={ 0 }>
{ icon }
<Box
as={ shouldHighlightHash ? 'mark' : 'span' }
......@@ -81,12 +94,16 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm, addressFormat }:
overflow="hidden"
whiteSpace="nowrap"
fontWeight={ 700 }
minWidth={ 0 }
>
{ addressEl }
</Box>
</Flex>
<Flex alignItems="center" justifyContent="space-between" gap={ 2 } minWidth={ 0 }>
{ nameEl }
{ tagEl }
</Flex>
</Grid>
);
};
......
......@@ -35,7 +35,8 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm
}
case 'contract':
case 'address':
case 'label': {
case 'label':
case 'metadata_tag': {
return route({ pathname: '/address/[hash]', query: { hash: data.address } });
}
case 'transaction': {
......@@ -73,6 +74,7 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm
/>
);
}
case 'metadata_tag':
case 'contract':
case 'address': {
return (
......
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