Commit 336fcf55 authored by isstuev's avatar isstuev

search

parent 09e4f139
import type { SearchResultToken, SearchResultBlock, SearchResultAddressOrContract, SearchResultTx, SearchResultLabel, SearchResult } from 'types/api/search';
import type {
SearchResultToken,
SearchResultBlock,
SearchResultAddressOrContract,
SearchResultTx,
SearchResultLabel,
SearchResult,
SearchResultUserOp,
} from 'types/api/search';
export const token1: SearchResultToken = {
address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
......@@ -101,6 +109,13 @@ export const tx1: SearchResultTx = {
url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
};
export const userOp1: SearchResultUserOp = {
timestamp: '2024-01-11T14:15:48.000000Z',
type: 'user_operation',
user_operation_hash: '0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
};
export const baseResponse: SearchResult = {
items: [
token1,
......
......@@ -55,7 +55,14 @@ export interface SearchResultTx {
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel;
export interface SearchResultUserOp {
type: 'user_operation';
user_operation_hash: string;
timestamp: string;
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp;
export interface SearchResult {
items: Array<SearchResultItem>;
......@@ -79,5 +86,5 @@ export interface SearchResultFilters {
export interface SearchRedirectResult {
parameter: string | null;
redirect: boolean;
type: 'address' | 'block' | 'transaction' | null;
type: 'address' | 'block' | 'transaction' | 'user_operation' | null;
}
......@@ -8,6 +8,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchResults from './SearchResults';
......@@ -157,6 +158,36 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(component.locator('main')).toHaveScreenshot();
});
const testWithUserOps = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.userOp1.user_operation_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.userOp1.user_operation_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.userOp1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test.describe('with apps', () => {
const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || '';
const extendedTest = test.extend({
......
......@@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import type { FormEvent } from 'react';
import React from 'react';
import config from 'configs/app';
import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps';
import SearchResultListItem from 'ui/searchResults/SearchResultListItem';
import SearchResultsInput from 'ui/searchResults/SearchResultsInput';
......@@ -52,6 +53,10 @@ const SearchResultsPageContent = () => {
router.replace({ pathname: '/tx/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return;
}
case 'user_operation': {
router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return;
}
}
}
......@@ -62,12 +67,19 @@ const SearchResultsPageContent = () => {
event.preventDefault();
}, [ ]);
const dataToDisplay = (data?.items || []).filter((item) => {
if (!config.features.userOps.isEnabled && item.type === 'user_operation') {
return false;
}
return true;
});
const content = (() => {
if (isError) {
return <DataFetchAlert/>;
}
const hasData = data?.items.length || (pagination.page === 1 && marketplaceApps.displayedApps.length);
const hasData = dataToDisplay.length || (pagination.page === 1 && marketplaceApps.displayedApps.length);
if (!hasData) {
return null;
......@@ -83,7 +95,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm }
/>
)) }
{ data && data.items.map((item, index) => (
{ dataToDisplay.map((item, index) => (
<SearchResultListItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item }
......@@ -110,7 +122,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm }
/>
)) }
{ data && data.items.map((item, index) => (
{ dataToDisplay.map((item, index) => (
<SearchResultTableItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item }
......@@ -130,7 +142,7 @@ const SearchResultsPageContent = () => {
return null;
}
const resultsCount = pagination.page === 1 && !data?.next_page_params ? (data?.items.length || 0) + marketplaceApps.displayedApps.length : '50+';
const resultsCount = pagination.page === 1 && !data?.next_page_params ? (dataToDisplay.length || 0) + marketplaceApps.displayedApps.length : '50+';
const text = isPlaceholderData && pagination.page === 1 ? (
<Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/>
......@@ -141,7 +153,7 @@ const SearchResultsPageContent = () => {
<chakra.span fontWeight={ 700 }>
{ resultsCount }
</chakra.span>
<span> matching result{ (((data?.items.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span>
<span> matching result{ (((dataToDisplay.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span>
<chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span>
</Box>
)
......
......@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEtity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
......@@ -56,7 +57,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
wordBreak="break-all"
isLoading={ isLoading }
onClick={ handleLinkClick }
flexGrow={ 1 }
overflow="hidden"
>
<Skeleton
......@@ -200,6 +200,26 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</TxEntity.Container>
);
}
case 'user_operation': {
return (
<UserOpEtity.Container>
<UserOpEtity.Icon/>
<UserOpEtity.Link
isLoading={ isLoading }
hash={ data.user_operation_hash }
onClick={ handleLinkClick }
>
<UserOpEtity.Content
asProp="mark"
hash={ data.user_operation_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</UserOpEtity.Link>
</UserOpEtity.Container>
);
}
}
})();
......@@ -240,6 +260,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'user_operation': {
return (
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'label': {
return (
<Flex alignItems="center">
......@@ -295,12 +321,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
return (
<ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }>
<Flex justifyContent="space-between" w="100%" overflow="hidden" lineHeight={ 6 }>
<Grid templateColumns="1fr auto" w="100%" overflow="hidden" lineHeight={ 6 }>
{ firstRow }
<Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize">
<span>{ category ? searchItemTitles[category].itemTitleShort : '' }</span>
</Skeleton>
</Flex>
</Grid>
{ Boolean(secondRow) && (
<Box w="100%" overflow="hidden" whiteSpace={ data.type !== 'app' ? 'nowrap' : undefined }>
{ secondRow }
......
......@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEtity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
......@@ -284,6 +285,33 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</>
);
}
case 'user_operation': {
return (
<>
<Td colSpan={ 2 } fontSize="sm">
<UserOpEtity.Container>
<UserOpEtity.Icon/>
<UserOpEtity.Link
isLoading={ isLoading }
hash={ data.user_operation_hash }
onClick={ handleLinkClick }
>
<UserOpEtity.Content
asProp="mark"
hash={ data.user_operation_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</UserOpEtity.Link>
</UserOpEtity.Container>
</Td>
<Td fontSize="sm" verticalAlign="middle" isNumeric>
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
</Td>
</>
);
}
}
})();
......
import type { SearchResultItem } from 'types/api/search';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block';
import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation';
export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap =
......@@ -23,6 +25,10 @@ export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'block', title: 'Blocks' },
];
if (config.features.userOps.isEnabled) {
searchCategories.push({ id: 'user_operation', title: 'User operations' });
}
export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = {
app: { itemTitle: 'App', itemTitleShort: 'App' },
token: { itemTitle: 'Token', itemTitleShort: 'Token' },
......@@ -31,6 +37,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
public_tag: { itemTitle: 'Public tag', itemTitleShort: 'Tag' },
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
};
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
......@@ -57,5 +64,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'app': {
return 'app';
}
case 'user_operation': {
return 'user_operation';
}
}
}
......@@ -9,6 +9,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchBar from './SearchBar';
......@@ -204,6 +205,35 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
const testWithUserOps = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.tx1.tx_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
searchMock.userOp1,
]),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search with view all link', async({ mount, page }) => {
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
......
......@@ -111,12 +111,12 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
}
if (!query.data || query.data.length === 0) {
const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
if (resultCategories.length === 0) {
return <Text>No results found.</Text>;
}
const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
return (
<>
{ resultCategories.length > 1 && (
......
......@@ -12,6 +12,7 @@ import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestToken from './SearchBarSuggestToken';
import SearchBarSuggestTx from './SearchBarSuggestTx';
import SearchBarSuggestUserOp from './SearchBarSuggestUserOp';
interface Props {
data: SearchResultItem;
......@@ -38,6 +39,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'block': {
return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } });
}
case 'user_operation': {
return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } });
}
}
})();
......@@ -60,6 +64,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'transaction': {
return <SearchBarSuggestTx data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
case 'user_operation': {
return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
}
})();
......
import { chakra, Text, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultUserOp } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultUserOp;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestTx = ({ data, isMobile }: Props) => {
const icon = <UserOpEntity.Icon/>;
const hash = (
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.user_operation_hash } isTooltipDisabled/>
</chakra.mark>
);
const date = dayjs(data.timestamp).format('llll');
if (isMobile) {
return (
<>
<Flex alignItems="center">
{ icon }
{ hash }
</Flex>
<Text variant="secondary">{ date }</Text>
</>
);
}
return (
<Flex columnGap={ 2 }>
<Flex alignItems="center" minW={ 0 }>
{ icon }
{ hash }
</Flex>
<Text variant="secondary" textAlign="end" flexShrink={ 0 } ml="auto">{ date }</Text>
</Flex>
);
};
export default React.memo(SearchBarSuggestTx);
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