mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
search
This commit is contained in:
+16
-1
@@ -1,4 +1,12 @@
|
||||
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,
|
||||
|
||||
+9
-2
@@ -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>
|
||||
)
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 }/>;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Reference in New Issue
Block a user