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

Public tags: dedicated tag page (#2217)

* page layout

* text with results number

* fix ts

* tests

* add quotes to network id env value

* fix null balance and result num

* fix mobile layout
parent cac3f631
......@@ -4,7 +4,7 @@ imagePullSecrets:
- name: regcred
config:
network:
id: 11155111
id: "11155111"
name: Blockscout
shortname: Blockscout
currency:
......
......@@ -38,7 +38,7 @@ import type {
AddressMudRecordsSorting,
AddressMudRecord,
} from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses';
import type { AddressesResponse, AddressesMetadataSearchResult, AddressesMetadataSearchFilters } from 'types/api/addresses';
import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata';
import type {
ArbitrumL2MessagesResponse,
......@@ -421,6 +421,10 @@ export const RESOURCES = {
path: '/api/v2/addresses/',
filterFields: [ ],
},
addresses_metadata_search: {
path: '/api/v2/proxy/metadata/addresses',
filterFields: [ 'slug' as const, 'tag_type' as const ],
},
// ADDRESS
address: {
......@@ -979,7 +983,7 @@ export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>
export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_rewards' |
'txs_validated' | 'txs_pending' | 'txs_with_blobs' | 'txs_watchlist' | 'txs_execution_node' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_blobs' |
'addresses' |
'addresses' | 'addresses_metadata_search' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' |
'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' |
......@@ -1053,6 +1057,7 @@ Q extends 'tx_state_changes' ? TxStateChanges :
Q extends 'tx_blobs' ? TxBlobs :
Q extends 'tx_interpretation' ? TxInterpretationResponse :
Q extends 'addresses' ? AddressesResponse :
Q extends 'addresses_metadata_search' ? AddressesMetadataSearchResult :
Q extends 'address' ? Address :
Q extends 'address_counters' ? AddressCounters :
Q extends 'address_tabs_counters' ? AddressTabsCounters :
......@@ -1178,6 +1183,7 @@ Q extends 'txs_with_blobs' ? TTxsWithBlobsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
Q extends 'addresses_metadata_search' ? AddressesMetadataSearchFilters :
Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'address_tokens' ? AddressTokensFilter :
Q extends 'address_nfts' ? AddressNFTTokensFilter :
......
......@@ -12,6 +12,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/block/countdown': 'Regular page',
'/block/countdown/[height]': 'Regular page',
'/accounts': 'Root page',
'/accounts/label/[slug]': 'Root page',
'/address/[hash]': 'Regular page',
'/verified-contracts': 'Root page',
'/contract-verification': 'Root page',
......
......@@ -16,6 +16,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/block/countdown': DEFAULT_TEMPLATE,
'/block/countdown/[height]': DEFAULT_TEMPLATE,
'/accounts': DEFAULT_TEMPLATE,
'/accounts/label/[slug]': DEFAULT_TEMPLATE,
'/address/[hash]': 'View the account balance, transactions, and other data for %hash% on the %network_title%',
'/verified-contracts': DEFAULT_TEMPLATE,
'/contract-verification': DEFAULT_TEMPLATE,
......
......@@ -12,6 +12,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/block/countdown': '%network_name% block countdown index',
'/block/countdown/[height]': '%network_name% block %height% countdown',
'/accounts': '%network_name% top accounts',
'/accounts/label/[slug]': '%network_name% addresses search by label',
'/address/[hash]': '%network_name% address details for %hash%',
'/verified-contracts': 'Verified %network_name% contracts lookup - %network_name% explorer',
'/contract-verification': '%network_name% verify contract',
......
......@@ -10,6 +10,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/block/countdown': 'Block countdown search',
'/block/countdown/[height]': 'Block countdown',
'/accounts': 'Top accounts',
'/accounts/label/[slug]': 'Addresses search by label',
'/address/[hash]': 'Address details',
'/verified-contracts': 'Verified contracts',
'/contract-verification': 'Contract verification',
......
......@@ -204,6 +204,16 @@ export const accounts: GetServerSideProps<Props> = async(context) => {
return base(context);
};
export const accountsLabelSearch: GetServerSideProps<Props> = async(context) => {
if (!config.features.addressMetadata.isEnabled || !context.query.tagType) {
return {
notFound: true,
};
}
return base(context);
};
export const userOps: GetServerSideProps<Props> = async(context) => {
if (!config.features.userOps.isEnabled) {
return {
......
......@@ -13,6 +13,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/account/verified-addresses">
| StaticRoute<"/account/watchlist">
| StaticRoute<"/accounts">
| DynamicRoute<"/accounts/label/[slug]", { "slug": string }>
| DynamicRoute<"/address/[hash]/contract-verification", { "hash": string }>
| DynamicRoute<"/address/[hash]", { "hash": string }>
| StaticRoute<"/api/config">
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const AccountsLabelSearch = dynamic(() => import('ui/pages/AccountsLabelSearch'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/accounts/label/[slug]">
<AccountsLabelSearch/>
</PageNextJs>
);
};
export default Page;
export { accountsLabelSearch as getServerSideProps } from 'nextjs/getServerSideProps';
import type { AddressParam } from './addressParams';
export type AddressesItem = AddressParam &{ tx_count: string; coin_balance: string }
export type AddressesItem = AddressParam & { tx_count: string; coin_balance: string | null }
export type AddressesResponse = {
items: Array<AddressesItem>;
......@@ -11,3 +11,13 @@ export type AddressesResponse = {
} | null;
total_supply: string;
}
export interface AddressesMetadataSearchResult {
items: Array<AddressesItem>;
next_page_params: null;
}
export interface AddressesMetadataSearchFilters {
slug: string;
tag_type: string;
}
......@@ -25,7 +25,7 @@ const AddressesListItem = ({
isLoading,
}: Props) => {
const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** config.chain.currency.decimals));
const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals));
return (
<ListItemMobile rowGap={ 3 }>
......
......@@ -24,7 +24,7 @@ const AddressesTableItem = ({
isLoading,
}: Props) => {
const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** config.chain.currency.decimals));
const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals));
const addressBalanceChunks = addressBalance.dp(8).toFormat().split('.');
return (
......
import { HStack, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AddressesItem } from 'types/api/addresses';
import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
type Props = {
item: AddressesItem;
isLoading?: boolean;
}
const AddressesLabelSearchListItem = ({
item,
isLoading,
}: Props) => {
const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals));
return (
<ListItemMobile rowGap={ 3 }>
<AddressEntity
address={ item }
isLoading={ isLoading }
fontWeight={ 700 }
w="100%"
/>
<HStack spacing={ 3 } maxW="100%" alignItems="flex-start">
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 } flexShrink={ 0 }>{ `Balance ${ currencyUnits.ether }` }</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" minW="0" whiteSpace="pre-wrap">
<span>{ addressBalance.dp(8).toFormat() }</span>
</Skeleton>
</HStack>
<HStack spacing={ 3 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Txn count</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary">
<span>{ Number(item.tx_count).toLocaleString() }</span>
</Skeleton>
</HStack>
</ListItemMobile>
);
};
export default React.memo(AddressesLabelSearchListItem);
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { AddressesItem } from 'types/api/addresses';
import { currencyUnits } from 'lib/units';
import { default as Thead } from 'ui/shared/TheadSticky';
import AddressesLabelSearchTableItem from './AddressesLabelSearchTableItem';
interface Props {
items: Array<AddressesItem>;
top: number;
isLoading?: boolean;
}
const AddressesLabelSearchTable = ({ items, top, isLoading }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th width="70%">Address</Th>
<Th width="15%" isNumeric>{ `Balance ${ currencyUnits.ether }` }</Th>
<Th width="15%" isNumeric>Txn count</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<AddressesLabelSearchTableItem
key={ item.hash + (isLoading ? index : '') }
item={ item }
isLoading={ isLoading }
/>
)) }
</Tbody>
</Table>
);
};
export default AddressesLabelSearchTable;
import { Tr, Td, Text, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AddressesItem } from 'types/api/addresses';
import config from 'configs/app';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
type Props = {
item: AddressesItem;
isLoading?: boolean;
}
const AddressesLabelSearchTableItem = ({
item,
isLoading,
}: Props) => {
const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals));
const addressBalanceChunks = addressBalance.dp(8).toFormat().split('.');
return (
<Tr>
<Td>
<AddressEntity
address={ item }
isLoading={ isLoading }
fontWeight={ 700 }
my="2px"
/>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading } display="inline-block" maxW="100%">
<Text lineHeight="24px" as="span">{ addressBalanceChunks[0] + (addressBalanceChunks[1] ? '.' : '') }</Text>
<Text lineHeight="24px" variant="secondary" as="span">{ addressBalanceChunks[1] }</Text>
</Skeleton>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading } display="inline-block" lineHeight="24px">
{ Number(item.tx_count).toLocaleString() }
</Skeleton>
</Td>
</Tr>
);
};
export default React.memo(AddressesLabelSearchTableItem);
import React from 'react';
import type { AddressesMetadataSearchResult } from 'types/api/addresses';
import * as addressMocks from 'mocks/address/address';
import { test, expect } from 'playwright/lib';
import AccountsLabelSearch from './AccountsLabelSearch';
const addresses: AddressesMetadataSearchResult = {
items: [
{
...addressMocks.withName,
tx_count: '1',
coin_balance: '12345678901234567890000',
},
{
...addressMocks.token,
tx_count: '109123890123',
coin_balance: '22222345678901234567890000',
ens_domain_name: null,
},
{
...addressMocks.withoutName,
tx_count: '11',
coin_balance: '1000000000000000000',
},
{
...addressMocks.eoa,
tx_count: '420',
coin_balance: null,
},
],
next_page_params: null,
};
const hooksConfig = {
router: {
query: {
slug: 'euler-finance-exploit',
tagType: 'generic',
tagName: 'Euler finance exploit',
},
},
};
test('base view +@mobile', async({ render, mockTextAd, mockApiResponse }) => {
await mockTextAd();
await mockApiResponse(
'addresses_metadata_search',
addresses,
{
queryParams: {
slug: 'euler-finance-exploit',
tag_type: 'generic',
},
},
);
const component = await render(<AccountsLabelSearch/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
import { chakra, Flex, Hide, Show, Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { EntityTag as TEntityTag, EntityTagType } from 'ui/shared/EntityTags/types';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TOP_ADDRESS } from 'stubs/address';
import { generateListStub } from 'stubs/utils';
import AddressesLabelSearchListItem from 'ui/addressesLabelSearch/AddressesLabelSearchListItem';
import AddressesLabelSearchTable from 'ui/addressesLabelSearch/AddressesLabelSearchTable';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import EntityTag from 'ui/shared/EntityTags/EntityTag';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
const AccountsLabelSearch = () => {
const router = useRouter();
const slug = getQueryParamString(router.query.slug);
const tagType = getQueryParamString(router.query.tagType);
const tagName = getQueryParamString(router.query.tagName);
const { isError, isPlaceholderData, data, pagination } = useQueryWithPages({
resourceName: 'addresses_metadata_search',
filters: {
slug,
tag_type: tagType,
},
options: {
placeholderData: generateListStub<'addresses_metadata_search'>(
TOP_ADDRESS,
50,
{
next_page_params: null,
},
),
},
});
const content = data?.items ? (
<>
<Hide below="lg" ssr={ false }>
<AddressesLabelSearchTable
top={ pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
items={ data.items }
isLoading={ isPlaceholderData }
/>
</Hide>
<Show below="lg" ssr={ false }>
{ data.items.map((item, index) => {
return (
<AddressesLabelSearchListItem
key={ item.hash + (isPlaceholderData ? index : '') }
item={ item }
isLoading={ isPlaceholderData }
/>
);
}) }
</Show>
</>
) : null;
const text = (() => {
if (isError) {
return null;
}
const num = data?.items.length || 0;
const tagData: TEntityTag = {
tagType: tagType as EntityTagType,
slug,
name: tagName || slug,
ordinal: 0,
};
return (
<Flex alignItems="center" columnGap={ 2 } flexWrap="wrap" rowGap={ 1 }>
<Skeleton
isLoaded={ !isPlaceholderData }
display="inline-block"
>
Found{ ' ' }
<chakra.span fontWeight={ 700 }>
{ num }{ data?.next_page_params || pagination.page > 1 ? '+' : '' }
</chakra.span>{ ' ' }
matching result{ num > 1 ? 's' : '' } for
</Skeleton>
<EntityTag data={ tagData } isLoading={ isPlaceholderData } noLink/>
</Flex>
);
})();
const actionBar = <StickyPaginationWithText text={ text } pagination={ pagination }/>;
return (
<>
<PageTitle title="Search result" withTextAd/>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText={ text }
content={ content }
actionBar={ actionBar }
/>
</>
);
};
export default AccountsLabelSearch;
......@@ -15,15 +15,16 @@ interface Props {
data: TEntityTag;
isLoading?: boolean;
maxW?: ResponsiveValue<string>;
noLink?: boolean;
}
const EntityTag = ({ data, isLoading, maxW }: Props) => {
const EntityTag = ({ data, isLoading, maxW, noLink }: Props) => {
if (isLoading) {
return <Skeleton borderRadius="sm" w="100px" h="24px"/>;
}
const hasLink = Boolean(getTagLinkParams(data));
const hasLink = !noLink && Boolean(getTagLinkParams(data));
const iconColor = data.meta?.textColor ?? 'gray.400';
const name = (() => {
......@@ -63,7 +64,7 @@ const EntityTag = ({ data, isLoading, maxW }: Props) => {
colorScheme={ hasLink ? 'gray-blue' : 'gray' }
_hover={ hasLink ? { opacity: 0.76 } : undefined }
>
<EntityTagLink data={ data }>
<EntityTagLink data={ data } noLink={ noLink }>
{ icon }
<TruncatedValue value={ name } tooltipPlacement="top"/>
</EntityTagLink>
......
......@@ -11,11 +11,12 @@ import { getTagLinkParams } from './utils';
interface Props {
data: EntityTag;
children: React.ReactNode;
noLink?: boolean;
}
const EntityTagLink = ({ data, children }: Props) => {
const EntityTagLink = ({ data, children, noLink }: Props) => {
const linkParams = getTagLinkParams(data);
const linkParams = !noLink ? getTagLinkParams(data) : undefined;
const handleLinkClick = React.useCallback(() => {
if (!linkParams?.href) {
......
import type { EntityTag } from './types';
// import { route } from 'nextjs-routes';
import { route } from 'nextjs-routes';
export function getTagLinkParams(data: EntityTag): { type: 'external' | 'internal'; href: string } | undefined {
if (data.meta?.warpcastHandle) {
......@@ -17,11 +17,10 @@ export function getTagLinkParams(data: EntityTag): { type: 'external' | 'interna
};
}
// Uncomment this block when "Tag search" page is ready - issue #1869
// if (data.tagType === 'generic' || data.tagType === 'protocol') {
// return {
// type: 'internal',
// href: route({ pathname: '/' }),
// };
// }
if (data.tagType === 'generic' || data.tagType === 'protocol') {
return {
type: 'internal',
href: route({ pathname: '/accounts/label/[slug]', query: { slug: data.slug, tagType: data.tagType, tagName: data.name } }),
};
}
}
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