Commit d392e5c6 authored by tom goriunov's avatar tom goriunov Committed by GitHub

BENS multiprotocol support (#2003)

* show protocol icon on the list and the entity page

* add logo and tooltip to the entity in address page subheading

* make icon_url and docs_url optional

* add protocol to name domain filters

* tests

* fetch protocols list from api endpoint

* fix protocol query param parsing

* build correct links to token ids and txs
parent d8df64aa
...@@ -52,6 +52,7 @@ import type { ...@@ -52,6 +52,7 @@ import type {
EnsDomainEventsResponse, EnsDomainEventsResponse,
EnsDomainLookupFilters, EnsDomainLookupFilters,
EnsDomainLookupResponse, EnsDomainLookupResponse,
EnsDomainProtocolsResponse,
EnsLookupSorting, EnsLookupSorting,
} from 'types/api/ens'; } from 'types/api/ens';
import type { IndexingStatus } from 'types/api/indexingStatus'; import type { IndexingStatus } from 'types/api/indexingStatus';
...@@ -219,7 +220,7 @@ export const RESOURCES = { ...@@ -219,7 +220,7 @@ export const RESOURCES = {
pathParams: [ 'chainId' as const ], pathParams: [ 'chainId' as const ],
endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint, endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint,
basePath: getFeaturePayload(config.features.nameService)?.api.basePath, basePath: getFeaturePayload(config.features.nameService)?.api.basePath,
filterFields: [ 'address' as const, 'resolved_to' as const, 'owned_by' as const, 'only_active' as const ], filterFields: [ 'address' as const, 'resolved_to' as const, 'owned_by' as const, 'only_active' as const, 'protocols' as const ],
}, },
domain_info: { domain_info: {
path: '/api/v1/:chainId/domains/:name', path: '/api/v1/:chainId/domains/:name',
...@@ -238,7 +239,13 @@ export const RESOURCES = { ...@@ -238,7 +239,13 @@ export const RESOURCES = {
pathParams: [ 'chainId' as const ], pathParams: [ 'chainId' as const ],
endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint, endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint,
basePath: getFeaturePayload(config.features.nameService)?.api.basePath, basePath: getFeaturePayload(config.features.nameService)?.api.basePath,
filterFields: [ 'name' as const, 'only_active' as const ], filterFields: [ 'name' as const, 'only_active' as const, 'protocols' as const ],
},
domain_protocols: {
path: '/api/v1/:chainId/protocols',
pathParams: [ 'chainId' as const ],
endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint,
basePath: getFeaturePayload(config.features.nameService)?.api.basePath,
}, },
// METADATA SERVICE & PUBLIC TAGS // METADATA SERVICE & PUBLIC TAGS
...@@ -1008,6 +1015,7 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse : ...@@ -1008,6 +1015,7 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed : Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse : Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse : Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'domain_protocols' ? EnsDomainProtocolsResponse :
Q extends 'user_ops' ? UserOpsResponse : Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp : Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount : Q extends 'user_ops_account' ? UserOpsAccount :
......
import type { EnsDomainDetailed } from 'types/api/ens'; import type { EnsDomainDetailed, EnsDomainProtocol } from 'types/api/ens';
const domainTokenA = { const domainTokenA = {
id: '97352314626701792030827861137068748433918254309635329404916858191911576754327', id: '97352314626701792030827861137068748433918254309635329404916858191911576754327',
...@@ -11,6 +11,34 @@ const domainTokenB = { ...@@ -11,6 +11,34 @@ const domainTokenB = {
type: 'WRAPPED_DOMAIN_TOKEN' as const, type: 'WRAPPED_DOMAIN_TOKEN' as const,
}; };
export const protocolA: EnsDomainProtocol = {
id: 'ens',
short_name: 'ENS',
title: 'Ethereum Name Service',
description: 'The Ethereum Name Service (ENS) is a distributed, open, and extensible naming system based on the Ethereum blockchain.',
tld_list: [
'eth',
'xyz',
],
icon_url: 'https://i.imgur.com/GOfUwCb.jpeg',
docs_url: 'https://docs.ens.domains/',
deployment_blockscout_base_url: 'http://localhost:3200/',
};
export const protocolB: EnsDomainProtocol = {
id: 'duck',
short_name: 'DUCK',
title: 'Duck Name Service',
description: '"Duck Name Service" is a cutting-edge blockchain naming service, providing seamless naming for crypto and decentralized applications. 🦆',
tld_list: [
'duck',
'quack',
],
icon_url: 'https://localhost:3000/duck.jpg',
docs_url: 'https://docs.duck.domains/',
deployment_blockscout_base_url: '',
};
export const ensDomainA: EnsDomainDetailed = { export const ensDomainA: EnsDomainDetailed = {
id: '0xb140bf9645e54f02ed3c1bcc225566b515a98d1688f10494a5c3bc5b447936a7', id: '0xb140bf9645e54f02ed3c1bcc225566b515a98d1688f10494a5c3bc5b447936a7',
tokens: [ tokens: [
...@@ -35,6 +63,7 @@ export const ensDomainA: EnsDomainDetailed = { ...@@ -35,6 +63,7 @@ export const ensDomainA: EnsDomainDetailed = {
GNO: 'DDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', GNO: 'DDAfbb505ad214D7b80b1f830fcCc89B60fb7A83',
NEAR: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.factory.bridge.near', NEAR: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.factory.bridge.near',
}, },
protocol: protocolA,
}; };
export const ensDomainB: EnsDomainDetailed = { export const ensDomainB: EnsDomainDetailed = {
...@@ -52,6 +81,7 @@ export const ensDomainB: EnsDomainDetailed = { ...@@ -52,6 +81,7 @@ export const ensDomainB: EnsDomainDetailed = {
registration_date: '2023-08-13T13:01:12.000Z', registration_date: '2023-08-13T13:01:12.000Z',
expiry_date: null, expiry_date: null,
other_addresses: {}, other_addresses: {},
protocol: null,
}; };
export const ensDomainC: EnsDomainDetailed = { export const ensDomainC: EnsDomainDetailed = {
...@@ -71,6 +101,7 @@ export const ensDomainC: EnsDomainDetailed = { ...@@ -71,6 +101,7 @@ export const ensDomainC: EnsDomainDetailed = {
registration_date: '2022-04-24T07:34:44.000Z', registration_date: '2022-04-24T07:34:44.000Z',
expiry_date: '2022-11-01T13:10:36.000Z', expiry_date: '2022-11-01T13:10:36.000Z',
other_addresses: {}, other_addresses: {},
protocol: null,
}; };
export const ensDomainD: EnsDomainDetailed = { export const ensDomainD: EnsDomainDetailed = {
...@@ -88,4 +119,5 @@ export const ensDomainD: EnsDomainDetailed = { ...@@ -88,4 +119,5 @@ export const ensDomainD: EnsDomainDetailed = {
registration_date: '2022-04-24T07:34:44.000Z', registration_date: '2022-04-24T07:34:44.000Z',
expiry_date: '2027-09-23T13:10:36.000Z', expiry_date: '2027-09-23T13:10:36.000Z',
other_addresses: {}, other_addresses: {},
protocol: null,
}; };
...@@ -22,6 +22,7 @@ export const ENS_DOMAIN: EnsDomainDetailed = { ...@@ -22,6 +22,7 @@ export const ENS_DOMAIN: EnsDomainDetailed = {
other_addresses: { other_addresses: {
ETH: ADDRESS_HASH, ETH: ADDRESS_HASH,
}, },
protocol: null,
}; };
export const ENS_DOMAIN_EVENT: EnsDomainEvent = { export const ENS_DOMAIN_EVENT: EnsDomainEvent = {
......
...@@ -12,6 +12,18 @@ export interface EnsDomain { ...@@ -12,6 +12,18 @@ export interface EnsDomain {
} | null; } | null;
registration_date?: string; registration_date?: string;
expiry_date: string | null; expiry_date: string | null;
protocol: EnsDomainProtocol | null;
}
export interface EnsDomainProtocol {
title: string;
description: string;
deployment_blockscout_base_url: string;
docs_url?: string;
icon_url?: string;
id: string;
short_name: string;
tld_list: Array<string>;
} }
export interface EnsDomainDetailed extends EnsDomain { export interface EnsDomainDetailed extends EnsDomain {
...@@ -43,6 +55,10 @@ export interface EnsDomainEventsResponse { ...@@ -43,6 +55,10 @@ export interface EnsDomainEventsResponse {
items: Array<EnsDomainEvent>; items: Array<EnsDomainEvent>;
} }
export interface EnsDomainProtocolsResponse {
items: Array<EnsDomainProtocol>;
}
export interface EnsDomainLookupResponse { export interface EnsDomainLookupResponse {
items: Array<EnsDomain>; items: Array<EnsDomain>;
next_page_params: { next_page_params: {
...@@ -56,11 +72,13 @@ export interface EnsAddressLookupFilters { ...@@ -56,11 +72,13 @@ export interface EnsAddressLookupFilters {
resolved_to: boolean; resolved_to: boolean;
owned_by: boolean; owned_by: boolean;
only_active: boolean; only_active: boolean;
protocols: Array<string> | undefined;
} }
export interface EnsDomainLookupFilters { export interface EnsDomainLookupFilters {
name: string | null; name: string | null;
only_active: boolean; only_active: boolean;
protocols: Array<string> | undefined;
} }
export interface EnsLookupSorting { export interface EnsLookupSorting {
......
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import config from 'configs/app'; import type { EnsAddressLookupResponse } from 'types/api/ens';
import type { ResourceError } from 'lib/api/resources';
import * as ensDomainMock from 'mocks/ens/domain'; import * as ensDomainMock from 'mocks/ens/domain';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
...@@ -8,10 +11,9 @@ import AddressEnsDomains from './AddressEnsDomains'; ...@@ -8,10 +11,9 @@ import AddressEnsDomains from './AddressEnsDomains';
const ADDRESS_HASH = ensDomainMock.ensDomainA.owner?.hash as string; const ADDRESS_HASH = ensDomainMock.ensDomainA.owner?.hash as string;
test('base view', async({ render, mockApiResponse, page }) => { test('base view', async({ render, page, mockAssetResponse }) => {
await mockApiResponse( const query = {
'addresses_lookup', data: {
{
items: [ items: [
ensDomainMock.ensDomainA, ensDomainMock.ensDomainA,
ensDomainMock.ensDomainB, ensDomainMock.ensDomainB,
...@@ -20,18 +22,18 @@ test('base view', async({ render, mockApiResponse, page }) => { ...@@ -20,18 +22,18 @@ test('base view', async({ render, mockApiResponse, page }) => {
], ],
next_page_params: null, next_page_params: null,
}, },
{ isPending: false,
pathParams: { chainId: config.chain.id }, isError: false,
queryParams: { } as unknown as UseQueryResult<EnsAddressLookupResponse, ResourceError<unknown>>;
address: ADDRESS_HASH, await mockAssetResponse(ensDomainMock.ensDomainA.protocol?.icon_url as string, './playwright/mocks/image_s.jpg');
resolved_to: true,
owned_by: true, const component = await render(
only_active: true, <AddressEnsDomains
order: 'ASC', query={ query }
}, addressHash={ ADDRESS_HASH }
}, mainDomainName={ ensDomainMock.ensDomainA.name }
/>,
); );
const component = await render(<AddressEnsDomains addressHash={ ADDRESS_HASH } mainDomainName={ ensDomainMock.ensDomainA.name }/>);
await component.getByText('4').click(); await component.getByText('4').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 550, height: 350 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 550, height: 350 } });
}); });
...@@ -13,15 +13,15 @@ import { ...@@ -13,15 +13,15 @@ import {
useDisclosure, useDisclosure,
chakra, chakra,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import _clamp from 'lodash/clamp'; import _clamp from 'lodash/clamp';
import React from 'react'; import React from 'react';
import type { EnsDomain } from 'types/api/ens'; import type { EnsAddressLookupResponse, EnsDomain } from 'types/api/ens';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity'; import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
...@@ -29,6 +29,7 @@ import LinkInternal from 'ui/shared/links/LinkInternal'; ...@@ -29,6 +29,7 @@ import LinkInternal from 'ui/shared/links/LinkInternal';
import PopoverTriggerTooltip from 'ui/shared/PopoverTriggerTooltip'; import PopoverTriggerTooltip from 'ui/shared/PopoverTriggerTooltip';
interface Props { interface Props {
query: UseQueryResult<EnsAddressLookupResponse, ResourceError<unknown>>;
addressHash: string; addressHash: string;
mainDomainName: string | null; mainDomainName: string | null;
} }
...@@ -41,24 +42,15 @@ const DomainsGrid = ({ data }: { data: Array<EnsDomain> }) => { ...@@ -41,24 +42,15 @@ const DomainsGrid = ({ data }: { data: Array<EnsDomain> }) => {
rowGap={ 4 } rowGap={ 4 }
mt={ 2 } mt={ 2 }
> >
{ data.slice(0, 9).map((domain) => <EnsEntity key={ domain.id } name={ domain.name } noCopy/>) } { data.slice(0, 9).map((domain) => <EnsEntity key={ domain.id } name={ domain.name } protocol={ domain.protocol } noCopy/>) }
</Grid> </Grid>
); );
}; };
const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => { const AddressEnsDomains = ({ query, addressHash, mainDomainName }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure(); const { isOpen, onToggle, onClose } = useDisclosure();
const { data, isPending, isError } = useApiQuery('addresses_lookup', { const { data, isPending, isError } = query;
pathParams: { chainId: config.chain.id },
queryParams: {
address: addressHash,
resolved_to: true,
owned_by: true,
only_active: true,
order: 'ASC',
},
});
if (isError) { if (isError) {
return null; return null;
...@@ -134,7 +126,7 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => { ...@@ -134,7 +126,7 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => {
<Box w="100%"> <Box w="100%">
<chakra.span color="text_secondary" fontSize="xs">Primary*</chakra.span> <chakra.span color="text_secondary" fontSize="xs">Primary*</chakra.span>
<Flex alignItems="center" fontSize="md" mt={ 2 }> <Flex alignItems="center" fontSize="md" mt={ 2 }>
<EnsEntity name={ mainDomain.name } fontWeight={ 600 } noCopy/> <EnsEntity name={ mainDomain.name } protocol={ mainDomain.protocol } fontWeight={ 600 } noCopy/>
{ mainDomain.expiry_date && { mainDomain.expiry_date &&
<chakra.span color="text_secondary" whiteSpace="pre"> (expires { dayjs(mainDomain.expiry_date).fromNow() })</chakra.span> } <chakra.span color="text_secondary" whiteSpace="pre"> (expires { dayjs(mainDomain.expiry_date).fromNow() })</chakra.span> }
</Flex> </Flex>
......
...@@ -6,8 +6,10 @@ import type { EnsDomainDetailed } from 'types/api/ens'; ...@@ -6,8 +6,10 @@ import type { EnsDomainDetailed } from 'types/api/ens';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import NftEntity from 'ui/shared/entities/nft/NftEntity'; import NftEntity from 'ui/shared/entities/nft/NftEntity';
...@@ -163,22 +165,33 @@ const NameDomainDetails = ({ query }: Props) => { ...@@ -163,22 +165,33 @@ const NameDomainDetails = ({ query }: Props) => {
</> </>
) } ) }
{ query.data?.tokens.map((token) => ( { query.data?.tokens.map((token) => {
<React.Fragment key={ token.type }> const isProtocolBaseChain = stripTrailingSlash(query.data.protocol?.deployment_blockscout_base_url ?? '') === config.app.baseUrl;
<DetailsInfoItem.Label const entityProps = {
hint={ `The ${ token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'wrapped ' : '' }token ID of this domain name NFT` } isExternal: !isProtocolBaseChain ? true : false,
isLoading={ isLoading } href: !isProtocolBaseChain ? (
> stripTrailingSlash(query.data.protocol?.deployment_blockscout_base_url ?? '') +
{ token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'Wrapped token ID' : 'Token ID' } route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.contract_hash, id: token.id } })
</DetailsInfoItem.Label> ) : undefined,
<DetailsInfoItem.Value };
wordBreak="break-all"
whiteSpace="pre-wrap" return (
> <React.Fragment key={ token.type }>
<NftEntity hash={ token.contract_hash } id={ token.id } isLoading={ isLoading } noIcon/> <DetailsInfoItem.Label
</DetailsInfoItem.Value> hint={ `The ${ token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'wrapped ' : '' }token ID of this domain name NFT` }
</React.Fragment> isLoading={ isLoading }
)) } >
{ token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'Wrapped token ID' : 'Token ID' }
</DetailsInfoItem.Label>
<DetailsInfoItem.Value
wordBreak="break-all"
whiteSpace="pre-wrap"
>
<NftEntity { ...entityProps } hash={ token.contract_hash } id={ token.id } isLoading={ isLoading } noIcon/>
</DetailsInfoItem.Value>
</React.Fragment>
);
}) }
{ otherAddresses.length > 0 && ( { otherAddresses.length > 0 && (
<> <>
......
...@@ -2,6 +2,8 @@ import { Box, Hide, Show } from '@chakra-ui/react'; ...@@ -2,6 +2,8 @@ import { Box, Hide, Show } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { EnsDomainDetailed } from 'types/api/ens';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -12,7 +14,11 @@ import NameDomainHistoryListItem from './history/NameDomainHistoryListItem'; ...@@ -12,7 +14,11 @@ import NameDomainHistoryListItem from './history/NameDomainHistoryListItem';
import NameDomainHistoryTable from './history/NameDomainHistoryTable'; import NameDomainHistoryTable from './history/NameDomainHistoryTable';
import { getNextSortValue, type Sort, type SortField } from './history/utils'; import { getNextSortValue, type Sort, type SortField } from './history/utils';
const NameDomainHistory = () => { interface Props {
domain: EnsDomainDetailed | undefined;
}
const NameDomainHistory = ({ domain }: Props) => {
const router = useRouter(); const router = useRouter();
const domainName = getQueryParamString(router.query.name); const domainName = getQueryParamString(router.query.name);
...@@ -40,12 +46,20 @@ const NameDomainHistory = () => { ...@@ -40,12 +46,20 @@ const NameDomainHistory = () => {
<> <>
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
<Box> <Box>
{ data?.items.map((item, index) => <NameDomainHistoryListItem key={ index } { ...item } isLoading={ isPlaceholderData }/>) } { data?.items.map((item, index) => (
<NameDomainHistoryListItem
key={ index }
event={ item }
domain={ domain }
isLoading={ isPlaceholderData }
/>
)) }
</Box> </Box>
</Show> </Show>
<Hide below="lg" ssr={ false }> <Hide below="lg" ssr={ false }>
<NameDomainHistoryTable <NameDomainHistoryTable
data={ data } history={ data }
domain={ domain }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
sort={ sort } sort={ sort }
onSortToggle={ handleSortToggle } onSortToggle={ handleSortToggle }
......
import { Skeleton } from '@chakra-ui/react'; import { Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { EnsDomainEvent } from 'types/api/ens'; import type { EnsDomainDetailed, EnsDomainEvent } from 'types/api/ens';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
type Props = EnsDomainEvent & { interface Props {
event: EnsDomainEvent;
domain: EnsDomainDetailed | undefined;
isLoading?: boolean; isLoading?: boolean;
} }
const NameDomainHistoryListItem = ({ isLoading, transaction_hash: transactionHash, timestamp, from_address: fromAddress, action }: Props) => { const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => {
const isProtocolBaseChain = stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') === config.app.baseUrl;
const txEntityProps = {
isExternal: !isProtocolBaseChain ? true : false,
href: !isProtocolBaseChain ? (
stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') +
route({ pathname: '/tx/[hash]', query: { hash: event.transaction_hash } })
) : undefined,
};
return ( return (
<ListItemMobileGrid.Container> <ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Txn hash</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Txn hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<TxEntity hash={ transactionHash } isLoading={ isLoading } fontWeight={ 500 } truncation="constant_long"/> <TxEntity { ...txEntityProps } hash={ event.transaction_hash } isLoading={ isLoading } fontWeight={ 500 } truncation="constant_long"/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block">
<span>{ dayjs(timestamp).fromNow() }</span> <span>{ dayjs(event.timestamp).fromNow() }</span>
</Skeleton> </Skeleton>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
{ fromAddress && ( { event.from_address && (
<> <>
<ListItemMobileGrid.Label isLoading={ isLoading }>From</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>From</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<AddressEntity address={ fromAddress } isLoading={ isLoading } truncation="constant"/> <AddressEntity address={ event.from_address } isLoading={ isLoading } truncation="constant"/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</> </>
) } ) }
{ action && ( { event.action && (
<> <>
<ListItemMobileGrid.Label isLoading={ isLoading }>Method</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Method</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Tag colorScheme="gray" isLoading={ isLoading }>{ action }</Tag> <Tag colorScheme="gray" isLoading={ isLoading }>{ event.action }</Tag>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</> </>
) } ) }
......
import { Table, Tbody, Tr, Th, Link } from '@chakra-ui/react'; import { Table, Tbody, Tr, Th, Link } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { EnsDomainEventsResponse } from 'types/api/ens'; import type { EnsDomainDetailed, EnsDomainEventsResponse } from 'types/api/ens';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
...@@ -11,13 +11,14 @@ import type { Sort } from './utils'; ...@@ -11,13 +11,14 @@ import type { Sort } from './utils';
import { sortFn } from './utils'; import { sortFn } from './utils';
interface Props { interface Props {
data: EnsDomainEventsResponse | undefined; history: EnsDomainEventsResponse | undefined;
domain: EnsDomainDetailed | undefined;
isLoading?: boolean; isLoading?: boolean;
sort: Sort | undefined; sort: Sort | undefined;
onSortToggle: (event: React.MouseEvent) => void; onSortToggle: (event: React.MouseEvent) => void;
} }
const NameDomainHistoryTable = ({ data, isLoading, sort, onSortToggle }: Props) => { const NameDomainHistoryTable = ({ history, domain, isLoading, sort, onSortToggle }: Props) => {
const sortIconTransform = sort?.includes('asc') ? 'rotate(-90deg)' : 'rotate(90deg)'; const sortIconTransform = sort?.includes('asc') ? 'rotate(-90deg)' : 'rotate(90deg)';
return ( return (
...@@ -47,10 +48,10 @@ const NameDomainHistoryTable = ({ data, isLoading, sort, onSortToggle }: Props) ...@@ -47,10 +48,10 @@ const NameDomainHistoryTable = ({ data, isLoading, sort, onSortToggle }: Props)
</Thead> </Thead>
<Tbody> <Tbody>
{ {
data?.items history?.items
.slice() .slice()
.sort(sortFn(sort)) .sort(sortFn(sort))
.map((item, index) => <NameDomainHistoryTableItem key={ index } { ...item } isLoading={ isLoading }/>) .map((item, index) => <NameDomainHistoryTableItem key={ index } event={ item } domain={ domain } isLoading={ isLoading }/>)
} }
</Tbody> </Tbody>
</Table> </Table>
......
import { Tr, Td, Skeleton } from '@chakra-ui/react'; import { Tr, Td, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { EnsDomainEvent } from 'types/api/ens'; import type { EnsDomainDetailed, EnsDomainEvent } from 'types/api/ens';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
type Props = EnsDomainEvent & { interface Props {
event: EnsDomainEvent;
domain: EnsDomainDetailed | undefined;
isLoading?: boolean; isLoading?: boolean;
} }
const NameDomainHistoryTableItem = ({ isLoading, transaction_hash: transactionHash, from_address: fromAddress, action, timestamp }: Props) => { const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => {
const isProtocolBaseChain = stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') === config.app.baseUrl;
const txEntityProps = {
isExternal: !isProtocolBaseChain ? true : false,
href: !isProtocolBaseChain ? (
stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') +
route({ pathname: '/tx/[hash]', query: { hash: event.transaction_hash } })
) : undefined,
};
return ( return (
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<TxEntity <TxEntity
hash={ transactionHash } { ...txEntityProps }
hash={ event.transaction_hash }
isLoading={ isLoading } isLoading={ isLoading }
fontWeight={ 700 } fontWeight={ 700 }
noIcon noIcon
...@@ -27,14 +42,14 @@ const NameDomainHistoryTableItem = ({ isLoading, transaction_hash: transactionHa ...@@ -27,14 +42,14 @@ const NameDomainHistoryTableItem = ({ isLoading, transaction_hash: transactionHa
</Td> </Td>
<Td pl={ 9 } verticalAlign="middle"> <Td pl={ 9 } verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block">
<span>{ dayjs(timestamp).fromNow() }</span> <span>{ dayjs(event.timestamp).fromNow() }</span>
</Skeleton> </Skeleton>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ fromAddress && <AddressEntity address={ fromAddress } isLoading={ isLoading } truncation="constant"/> } { event.from_address && <AddressEntity address={ event.from_address } isLoading={ isLoading } truncation="constant"/> }
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ action && <Tag colorScheme="gray" isLoading={ isLoading }>{ action }</Tag> } { event.action && <Tag colorScheme="gray" isLoading={ isLoading }>{ event.action }</Tag> }
</Td> </Td>
</Tr> </Tr>
); );
......
import { Checkbox, CheckboxGroup, HStack, Text } from '@chakra-ui/react'; import { Box, Checkbox, CheckboxGroup, Flex, HStack, Image, Link, Text, VStack, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { EnsDomainLookupFiltersOptions } from 'types/api/ens'; import type { EnsDomainLookupFiltersOptions, EnsDomainProtocol } from 'types/api/ens';
import type { PaginationParams } from 'ui/shared/pagination/types'; import type { PaginationParams } from 'ui/shared/pagination/types';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading'; import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import FilterInput from 'ui/shared/filters/FilterInput'; import FilterInput from 'ui/shared/filters/FilterInput';
import PopoverFilter from 'ui/shared/filters/PopoverFilter'; import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import IconSvg from 'ui/shared/IconSvg';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import Sort from 'ui/shared/sort/Sort'; import Sort from 'ui/shared/sort/Sort';
...@@ -20,6 +21,9 @@ interface Props { ...@@ -20,6 +21,9 @@ interface Props {
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
filterValue: EnsDomainLookupFiltersOptions; filterValue: EnsDomainLookupFiltersOptions;
onFilterValueChange: (nextValue: EnsDomainLookupFiltersOptions) => void; onFilterValueChange: (nextValue: EnsDomainLookupFiltersOptions) => void;
protocolsData: Array<EnsDomainProtocol> | undefined;
protocolsFilterValue: Array<string>;
onProtocolsFilterChange: (nextValue: Array<string>) => void;
sort: TSort | undefined; sort: TSort | undefined;
onSortChange: (nextValue: TSort | undefined) => void; onSortChange: (nextValue: TSort | undefined) => void;
isLoading: boolean; isLoading: boolean;
...@@ -36,6 +40,9 @@ const NameDomainsActionBar = ({ ...@@ -36,6 +40,9 @@ const NameDomainsActionBar = ({
isLoading, isLoading,
isAddressSearch, isAddressSearch,
pagination, pagination,
protocolsData,
protocolsFilterValue,
onProtocolsFilterChange,
}: Props) => { }: Props) => {
const isInitialLoading = useIsInitialLoading(isLoading); const isInitialLoading = useIsInitialLoading(isLoading);
...@@ -51,26 +58,72 @@ const NameDomainsActionBar = ({ ...@@ -51,26 +58,72 @@ const NameDomainsActionBar = ({
/> />
); );
const handleProtocolReset = React.useCallback(() => {
onProtocolsFilterChange([]);
}, [ onProtocolsFilterChange ]);
const filterGroupDivider = <Box w="100%" borderBottomWidth="1px" borderBottomColor="divider" my={ 4 }/>;
const appliedFiltersNum = filterValue.length + (protocolsData && protocolsData.length > 1 ? protocolsFilterValue.length : 0);
const filter = ( const filter = (
<PopoverFilter appliedFiltersNum={ filterValue.length } contentProps={{ w: '220px' }} isLoading={ isInitialLoading }> <PopoverFilter appliedFiltersNum={ appliedFiltersNum } contentProps={{ minW: '220px', w: 'fit-content' }} isLoading={ isInitialLoading }>
<div> <div>
{ protocolsData && protocolsData.length > 1 && (
<>
<Flex justifyContent="space-between" fontSize="sm" mb={ 3 }>
<Text fontWeight={ 600 } variant="secondary">Protocol</Text>
<Link
onClick={ handleProtocolReset }
color={ protocolsData.length > 0 ? 'link' : 'text_secondary' }
_hover={{
color: protocolsData.length > 0 ? 'link_hovered' : 'text_secondary',
}}
>
Reset
</Link>
</Flex>
<CheckboxGroup size="lg" value={ protocolsFilterValue } defaultValue={ protocolsFilterValue } onChange={ onProtocolsFilterChange }>
<VStack gap={ 5 } alignItems="flex-start">
{ protocolsData.map((protocol) => {
const topLevelDomains = protocol.tld_list.map((domain) => `.${ domain }`).join(' ');
return (
<Checkbox key={ protocol.id } value={ protocol.id }>
<Flex alignItems="center">
<Image
src={ protocol.icon_url }
boxSize={ 5 }
borderRadius="sm"
mr={ 2 }
alt={ `${ protocol.title } protocol icon` }
fallback={ <IconSvg name="ENS_slim" boxSize={ 5 } mr={ 2 }/> }
fallbackStrategy={ protocol.icon_url ? 'onError' : 'beforeLoadOrError' }
/>
<span>{ protocol.short_name }</span>
<chakra.span color="text_secondary" whiteSpace="pre"> { topLevelDomains }</chakra.span>
</Flex>
</Checkbox>
);
}) }
</VStack>
</CheckboxGroup>
{ filterGroupDivider }
</>
) }
<CheckboxGroup size="lg" onChange={ onFilterValueChange } value={ filterValue } defaultValue={ filterValue }> <CheckboxGroup size="lg" onChange={ onFilterValueChange } value={ filterValue } defaultValue={ filterValue }>
<Text variant="secondary" fontWeight={ 600 } mb={ 3 } fontSize="sm">Address</Text> <Text variant="secondary" fontWeight={ 600 } mb={ 3 } fontSize="sm">Address</Text>
<Checkbox value="owned_by" display="block" isDisabled={ !isAddressSearch }> <Checkbox value="owned_by" isDisabled={ !isAddressSearch } display="block">
Owned by Owned by
</Checkbox> </Checkbox>
<Checkbox <Checkbox
value="resolved_to" value="resolved_to"
display="block"
mt={ 5 } mt={ 5 }
mb={ 4 }
pb={ 4 }
borderBottom="1px solid"
borderColor="divider"
isDisabled={ !isAddressSearch } isDisabled={ !isAddressSearch }
display="block"
> >
Resolved to address Resolved to address
</Checkbox> </Checkbox>
{ filterGroupDivider }
<Text variant="secondary" fontWeight={ 600 } mb={ 3 } fontSize="sm">Status</Text> <Text variant="secondary" fontWeight={ 600 } mb={ 3 } fontSize="sm">Status</Text>
<Checkbox value="with_inactive" display="block"> <Checkbox value="with_inactive" display="block">
Include expired Include expired
......
...@@ -13,12 +13,19 @@ interface Props extends EnsDomain { ...@@ -13,12 +13,19 @@ interface Props extends EnsDomain {
isLoading: boolean; isLoading: boolean;
} }
const NameDomainsListItem = ({ name, isLoading, resolved_address: resolvedAddress, registration_date: registrationDate, expiry_date: expiryDate }: Props) => { const NameDomainsListItem = ({
name,
isLoading,
resolved_address: resolvedAddress,
registration_date: registrationDate,
expiry_date: expiryDate,
protocol,
}: Props) => {
return ( return (
<ListItemMobileGrid.Container> <ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Domain</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Domain</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<EnsEntity name={ name } isLoading={ isLoading } fontWeight={ 500 }/> <EnsEntity name={ name } protocol={ protocol } isLoading={ isLoading } fontWeight={ 500 }/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
{ resolvedAddress && ( { resolvedAddress && (
......
...@@ -12,12 +12,19 @@ type Props = EnsDomain & { ...@@ -12,12 +12,19 @@ type Props = EnsDomain & {
isLoading?: boolean; isLoading?: boolean;
} }
const NameDomainsTableItem = ({ isLoading, name, resolved_address: resolvedAddress, registration_date: registrationDate, expiry_date: expiryDate }: Props) => { const NameDomainsTableItem = ({
isLoading,
name,
resolved_address: resolvedAddress,
registration_date: registrationDate,
expiry_date: expiryDate,
protocol,
}: Props) => {
return ( return (
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<EnsEntity name={ name } isLoading={ isLoading } fontWeight={ 600 }/> <EnsEntity name={ name } protocol={ protocol } isLoading={ isLoading } fontWeight={ 600 }/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ resolvedAddress && <AddressEntity address={ resolvedAddress } isLoading={ isLoading } fontWeight={ 500 }/> } { resolvedAddress && <AddressEntity address={ resolvedAddress } isLoading={ isLoading } fontWeight={ 500 }/> }
......
...@@ -78,6 +78,23 @@ const AddressPageContent = () => { ...@@ -78,6 +78,23 @@ const AddressPageContent = () => {
const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]); const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery); const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
const addressEnsDomainsQuery = useApiQuery('addresses_lookup', {
pathParams: { chainId: config.chain.id },
queryParams: {
address: hash,
resolved_to: true,
owned_by: true,
only_active: true,
order: 'ASC',
},
queryOptions: {
enabled: Boolean(hash) && config.features.nameService.isEnabled,
},
});
const addressMainDomain = !addressQuery.isPlaceholderData ?
addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) :
undefined;
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData); const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData; const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
...@@ -238,6 +255,7 @@ const AddressPageContent = () => { ...@@ -238,6 +255,7 @@ const AddressPageContent = () => {
{ addressQuery.data?.ens_domain_name && ( { addressQuery.data?.ens_domain_name && (
<EnsEntity <EnsEntity
name={ addressQuery.data?.ens_domain_name } name={ addressQuery.data?.ens_domain_name }
protocol={ !addressEnsDomainsQuery.isPending ? addressMainDomain?.protocol : null }
fontFamily="heading" fontFamily="heading"
fontSize="lg" fontSize="lg"
fontWeight={ 500 } fontWeight={ 500 }
...@@ -267,7 +285,7 @@ const AddressPageContent = () => { ...@@ -267,7 +285,7 @@ const AddressPageContent = () => {
{ !isLoading && addressQuery.data?.is_contract && addressQuery.data?.is_verified && config.UI.views.address.solidityscanEnabled && { !isLoading && addressQuery.data?.is_contract && addressQuery.data?.is_verified && config.UI.views.address.solidityscanEnabled &&
<SolidityscanReport hash={ hash }/> } <SolidityscanReport hash={ hash }/> }
{ !isLoading && addressQuery.data && config.features.nameService.isEnabled && { !isLoading && addressQuery.data && config.features.nameService.isEnabled &&
<AddressEnsDomains addressHash={ hash } mainDomainName={ addressQuery.data.ens_domain_name }/> } <AddressEnsDomains query={ addressEnsDomainsQuery } addressHash={ hash } mainDomainName={ addressQuery.data.ens_domain_name }/> }
<NetworkExplorers type="address" pathParam={ hash }/> <NetworkExplorers type="address" pathParam={ hash }/>
</Flex> </Flex>
); );
......
...@@ -36,7 +36,7 @@ const NameDomain = () => { ...@@ -36,7 +36,7 @@ const NameDomain = () => {
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
{ id: 'details', title: 'Details', component: <NameDomainDetails query={ infoQuery }/> }, { id: 'details', title: 'Details', component: <NameDomainDetails query={ infoQuery }/> },
{ id: 'history', title: 'History', component: <NameDomainHistory/> }, { id: 'history', title: 'History', component: <NameDomainHistory domain={ infoQuery.data }/> },
]; ];
const tabIndex = useTabIndexFromQuery(tabs); const tabIndex = useTabIndexFromQuery(tabs);
...@@ -58,6 +58,7 @@ const NameDomain = () => { ...@@ -58,6 +58,7 @@ const NameDomain = () => {
> >
<EnsEntity <EnsEntity
name={ domainName } name={ domainName }
protocol={ infoQuery.data?.protocol }
isLoading={ isLoading } isLoading={ isLoading }
noLink noLink
maxW={{ lg: infoQuery.data?.resolved_address ? '300px' : 'min-content' }} maxW={{ lg: infoQuery.data?.resolved_address ? '300px' : 'min-content' }}
......
...@@ -6,8 +6,10 @@ import { test, expect } from 'playwright/lib'; ...@@ -6,8 +6,10 @@ import { test, expect } from 'playwright/lib';
import NameDomains from './NameDomains'; import NameDomains from './NameDomains';
test('default view +@mobile', async({ render, mockApiResponse, mockTextAd }) => { test.beforeEach(async({ mockApiResponse, mockAssetResponse, mockTextAd }) => {
await mockTextAd(); await mockTextAd();
await mockAssetResponse(ensDomainMock.protocolA.icon_url as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(ensDomainMock.protocolB.icon_url as string, './playwright/mocks/image_md.jpg');
await mockApiResponse('domains_lookup', { await mockApiResponse('domains_lookup', {
items: [ items: [
ensDomainMock.ensDomainA, ensDomainMock.ensDomainA,
...@@ -23,7 +25,21 @@ test('default view +@mobile', async({ render, mockApiResponse, mockTextAd }) => ...@@ -23,7 +25,21 @@ test('default view +@mobile', async({ render, mockApiResponse, mockTextAd }) =>
pathParams: { chainId: config.chain.id }, pathParams: { chainId: config.chain.id },
queryParams: { only_active: true }, queryParams: { only_active: true },
}); });
await mockApiResponse('domain_protocols', {
items: [ ensDomainMock.protocolA, ensDomainMock.protocolB ],
}, {
pathParams: { chainId: config.chain.id },
});
});
test('default view +@mobile', async({ render }) => {
const component = await render(<NameDomains/>); const component = await render(<NameDomains/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('filters', async({ render, page }) => {
const component = await render(<NameDomains/>);
await component.getByRole('button', { name: 'Filter' }).click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 500 } });
});
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import type { EnsDomainLookupFiltersOptions, EnsLookupSorting } from 'types/api/ens'; import type { EnsDomainLookupFiltersOptions, EnsLookupSorting } from 'types/api/ens';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce'; import useDebounce from 'lib/hooks/useDebounce';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -29,6 +30,7 @@ const NameDomains = () => { ...@@ -29,6 +30,7 @@ const NameDomains = () => {
const ownedBy = getQueryParamString(router.query.owned_by); const ownedBy = getQueryParamString(router.query.owned_by);
const resolvedTo = getQueryParamString(router.query.resolved_to); const resolvedTo = getQueryParamString(router.query.resolved_to);
const onlyActive = getQueryParamString(router.query.only_active); const onlyActive = getQueryParamString(router.query.only_active);
const protocols = Array.isArray(router.query.protocols) ? router.query.protocols : (router.query.protocols ?? '').split(',').filter(Boolean);
const initialFilters: EnsDomainLookupFiltersOptions = [ const initialFilters: EnsDomainLookupFiltersOptions = [
ownedBy === 'true' ? 'owned_by' as const : undefined, ownedBy === 'true' ? 'owned_by' as const : undefined,
...@@ -40,6 +42,7 @@ const NameDomains = () => { ...@@ -40,6 +42,7 @@ const NameDomains = () => {
const [ searchTerm, setSearchTerm ] = React.useState<string>(q || ''); const [ searchTerm, setSearchTerm ] = React.useState<string>(q || '');
const [ filterValue, setFilterValue ] = React.useState<EnsDomainLookupFiltersOptions>(initialFilters); const [ filterValue, setFilterValue ] = React.useState<EnsDomainLookupFiltersOptions>(initialFilters);
const [ sort, setSort ] = React.useState<Sort | undefined>(initialSort); const [ sort, setSort ] = React.useState<Sort | undefined>(initialSort);
const [ protocolsFilter, setProtocolsFilter ] = React.useState<Array<string>>(protocols);
const debouncedSearchTerm = useDebounce(searchTerm, 300); const debouncedSearchTerm = useDebounce(searchTerm, 300);
const isAddressSearch = React.useMemo(() => ADDRESS_REGEXP.test(debouncedSearchTerm), [ debouncedSearchTerm ]); const isAddressSearch = React.useMemo(() => ADDRESS_REGEXP.test(debouncedSearchTerm), [ debouncedSearchTerm ]);
...@@ -53,6 +56,7 @@ const NameDomains = () => { ...@@ -53,6 +56,7 @@ const NameDomains = () => {
resolved_to: filterValue.includes('resolved_to'), resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'), owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'), only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined,
}, },
sorting: sortParams, sorting: sortParams,
options: { options: {
...@@ -67,6 +71,7 @@ const NameDomains = () => { ...@@ -67,6 +71,7 @@ const NameDomains = () => {
filters: { filters: {
name: debouncedSearchTerm, name: debouncedSearchTerm,
only_active: !filterValue.includes('with_inactive'), only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined,
}, },
sorting: sortParams, sorting: sortParams,
options: { options: {
...@@ -75,6 +80,10 @@ const NameDomains = () => { ...@@ -75,6 +80,10 @@ const NameDomains = () => {
}, },
}); });
const protocolsQuery = useApiQuery('domain_protocols', {
pathParams: { chainId: config.chain.id },
});
const query = isAddressSearch ? addressesLookupQuery : domainsLookupQuery; const query = isAddressSearch ? addressesLookupQuery : domainsLookupQuery;
const { data, isError, isPlaceholderData: isLoading, onFilterChange, onSortingChange } = query; const { data, isError, isPlaceholderData: isLoading, onFilterChange, onSortingChange } = query;
...@@ -87,12 +96,14 @@ const NameDomains = () => { ...@@ -87,12 +96,14 @@ const NameDomains = () => {
resolved_to: true, resolved_to: true,
owned_by: true, owned_by: true,
only_active: !hasInactiveFilter, only_active: !hasInactiveFilter,
protocols: protocolsFilter,
}); });
} else { } else {
setFilterValue([ hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean)); setFilterValue([ hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean));
onFilterChange<'domains_lookup'>({ onFilterChange<'domains_lookup'>({
name: debouncedSearchTerm, name: debouncedSearchTerm,
only_active: !hasInactiveFilter, only_active: !hasInactiveFilter,
protocols: protocolsFilter,
}); });
} }
// should run only the type of search changes // should run only the type of search changes
...@@ -123,14 +134,16 @@ const NameDomains = () => { ...@@ -123,14 +134,16 @@ const NameDomains = () => {
resolved_to: filterValue.includes('resolved_to'), resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'), owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'), only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter,
}); });
} else { } else {
onFilterChange<'domains_lookup'>({ onFilterChange<'domains_lookup'>({
name: value, name: value,
only_active: !filterValue.includes('with_inactive'), only_active: !filterValue.includes('with_inactive'),
protocols: protocolsFilter,
}); });
} }
}, [ onFilterChange, filterValue ]); }, [ onFilterChange, filterValue, protocolsFilter ]);
const handleFilterValueChange = React.useCallback((value: EnsDomainLookupFiltersOptions) => { const handleFilterValueChange = React.useCallback((value: EnsDomainLookupFiltersOptions) => {
setFilterValue(value); setFilterValue(value);
...@@ -142,16 +155,40 @@ const NameDomains = () => { ...@@ -142,16 +155,40 @@ const NameDomains = () => {
resolved_to: value.includes('resolved_to'), resolved_to: value.includes('resolved_to'),
owned_by: value.includes('owned_by'), owned_by: value.includes('owned_by'),
only_active: !value.includes('with_inactive'), only_active: !value.includes('with_inactive'),
protocols: protocolsFilter,
}); });
} else { } else {
onFilterChange<'domains_lookup'>({ onFilterChange<'domains_lookup'>({
name: debouncedSearchTerm, name: debouncedSearchTerm,
only_active: !value.includes('with_inactive'), only_active: !value.includes('with_inactive'),
protocols: protocolsFilter,
});
}
}, [ debouncedSearchTerm, onFilterChange, protocolsFilter ]);
const handleProtocolsFilterChange = React.useCallback((nextValue: Array<string>) => {
setProtocolsFilter(nextValue);
const isAddressSearch = ADDRESS_REGEXP.test(debouncedSearchTerm);
if (isAddressSearch) {
onFilterChange<'addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
protocols: nextValue,
});
} else {
onFilterChange<'domains_lookup'>({
name: debouncedSearchTerm,
only_active: !filterValue.includes('with_inactive'),
protocols: nextValue,
}); });
} }
}, [ debouncedSearchTerm, onFilterChange ]); }, [ debouncedSearchTerm, filterValue, onFilterChange ]);
const hasActiveFilters = Boolean(debouncedSearchTerm) || filterValue.length > 0; const hasActiveFilters = Boolean(debouncedSearchTerm) || filterValue.length > 0 ||
(protocolsQuery.data && protocolsQuery.data.items.length > 1 ? protocolsFilter.length > 0 : false);
const content = ( const content = (
<> <>
...@@ -184,6 +221,9 @@ const NameDomains = () => { ...@@ -184,6 +221,9 @@ const NameDomains = () => {
onSearchChange={ handleSearchTermChange } onSearchChange={ handleSearchTermChange }
filterValue={ filterValue } filterValue={ filterValue }
onFilterValueChange={ handleFilterValueChange } onFilterValueChange={ handleFilterValueChange }
protocolsData={ protocolsQuery.data?.items }
protocolsFilterValue={ protocolsFilter }
onProtocolsFilterChange={ handleProtocolsFilterChange }
sort={ sort } sort={ sort }
onSortChange={ setSort } onSortChange={ setSort }
isAddressSearch={ isAddressSearch } isAddressSearch={ isAddressSearch }
......
import React from 'react'; import React from 'react';
import * as domainMock from 'mocks/ens/domain';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import EnsEntity from './EnsEntity'; import EnsEntity from './EnsEntity';
...@@ -59,3 +60,22 @@ test('customization', async({ render }) => { ...@@ -59,3 +60,22 @@ test('customization', async({ render }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test.describe('', () => {
test.use({ viewport: { width: 300, height: 400 } });
test('with protocol info', async({ render, page, mockAssetResponse }) => {
await mockAssetResponse(domainMock.ensDomainA.protocol?.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(
<EnsEntity
name={ name }
protocol={ domainMock.protocolA }
/>,
);
await component.getByAltText(`${ domainMock.protocolA.title } protocol icon`).first().hover();
await expect(page.getByText(domainMock.protocolA.description)).toBeVisible();
await expect(page).toHaveScreenshot();
});
});
import { chakra } from '@chakra-ui/react'; import { chakra, Flex, Image, Popover, PopoverBody, PopoverContent, PopoverTrigger, Portal, Skeleton, Text } from '@chakra-ui/react';
import _omit from 'lodash/omit'; import _omit from 'lodash/omit';
import React from 'react'; import React from 'react';
import type { EnsDomainProtocol } from 'types/api/ens';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components'; import * as EntityBase from 'ui/shared/entities/base/components';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal';
import TruncatedValue from 'ui/shared/TruncatedValue'; import TruncatedValue from 'ui/shared/TruncatedValue';
import { getIconProps } from '../base/utils';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'name'>; type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'name'>;
const Link = chakra((props: LinkProps) => { const Link = chakra((props: LinkProps) => {
...@@ -22,17 +28,73 @@ const Link = chakra((props: LinkProps) => { ...@@ -22,17 +28,73 @@ const Link = chakra((props: LinkProps) => {
); );
}); });
type IconProps = Omit<EntityBase.IconBaseProps, 'name'> & { type IconProps = Omit<EntityBase.IconBaseProps, 'name'> & Pick<EntityProps, 'protocol'> & {
iconName?: EntityBase.IconBaseProps['name']; iconName?: EntityBase.IconBaseProps['name'];
}; };
const Icon = (props: IconProps) => { const Icon = (props: IconProps) => {
return ( const icon = <EntityBase.Icon { ...props } name={ props.iconName ?? 'ENS_slim' }/>;
<EntityBase.Icon
{ ...props } if (props.protocol) {
name={ props.iconName ?? 'ENS_slim' } const styles = getIconProps(props.iconSize);
/>
); if (props.isLoading) {
return <Skeleton boxSize={ styles.boxSize } borderRadius="sm" mr={ 2 }/>;
}
return (
<Popover trigger="hover" isLazy placement="bottom-start">
<PopoverTrigger>
<div>
<Image
src={ props.protocol.icon_url }
boxSize={ styles.boxSize }
borderRadius="sm"
mr={ 2 }
alt={ `${ props.protocol.title } protocol icon` }
fallback={ icon }
fallbackStrategy={ props.protocol.icon_url ? 'onError' : 'beforeLoadOrError' }
/>
</div>
</PopoverTrigger>
<Portal>
<PopoverContent maxW={{ base: '100vw', lg: '440px' }} minW="250px" w="fit-content">
<PopoverBody display="flex" flexDir="column" rowGap={ 3 }>
<Flex alignItems="center">
<Image
src={ props.protocol.icon_url }
boxSize={ 5 }
borderRadius="sm"
mr={ 2 }
alt={ `${ props.protocol.title } protocol icon` }
fallback={ icon }
fallbackStrategy={ props.protocol.icon_url ? 'onError' : 'beforeLoadOrError' }
/>
<div>
<span>{ props.protocol.short_name }</span>
<chakra.span color="text_secondary" whiteSpace="pre"> { props.protocol.tld_list.map((tld) => `.${ tld }`).join((' ')) }</chakra.span>
</div>
</Flex>
<Text fontSize="sm">{ props.protocol.description }</Text>
{ props.protocol.docs_url && (
<LinkExternal
href={ props.protocol.docs_url }
display="inline-flex"
alignItems="center"
fontSize="sm"
>
<IconSvg name="docs" boxSize={ 5 } color="text_secondary" mr={ 2 }/>
<span>Documentation</span>
</LinkExternal>
) }
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
}
return icon;
}; };
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'name'>; type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'name'>;
...@@ -61,6 +123,7 @@ const Container = EntityBase.Container; ...@@ -61,6 +123,7 @@ const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps { export interface EntityProps extends EntityBase.EntityBaseProps {
name: string; name: string;
protocol?: EnsDomainProtocol | null;
} }
const EnsEntity = (props: EntityProps) => { const EnsEntity = (props: EntityProps) => {
......
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