Commit f3f365b8 authored by isstuev's avatar isstuev

add categories and link to all

parent 97af5224
......@@ -188,24 +188,51 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search with simple match', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.token2.name }`;
const API_CHECK_REDIRECT_URL = buildApiUrl('search_check_redirect') + `?q=${ searchMock.token2.name }`;
test('search with view all link', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
next_page_params: { foo: 'bar' },
}),
}));
await page.route(API_CHECK_REDIRECT_URL, (route) => route.fulfill({
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
});
test('scroll suggest to category', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
parameter: searchMock.token2.address,
redirect: true,
type: 'address',
items: [
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
}),
}));
......@@ -214,18 +241,12 @@ test('search with simple match', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.token2.name);
await page.getByPlaceholder(/search/i).type('o');
await page.waitForResponse(API_URL);
await page.waitForResponse(API_CHECK_REDIRECT_URL);
const resultText = page.getByText('Found 2 matching result');
await expect(resultText).toBeVisible();
await page.getByRole('tab', { name: 'Addresses' }).click();
const linkToToken = page.getByText(searchMock.token2.name);
await expect(linkToToken).toHaveCount(1);
const linkToAddress = page.getByText(searchMock.token2.address);
await expect(linkToAddress).toHaveCount(2);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
});
test('recent keywords suggest +@mobile', async({ mount, page }) => {
......
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react';
import { Box, Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure, PopoverFooter } from '@chakra-ui/react';
import _debounce from 'lodash/debounce';
import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
import type { FormEvent, FocusEvent } from 'react';
import React from 'react';
import { Element } from 'react-scroll';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import { getRecentSearchKeywords, saveToRecentKeywords } from 'lib/recentSearchKeywords';
import LinkInternal from 'ui/shared/LinkInternal';
import SearchBarInput from './SearchBarInput';
import SearchBarRecentKeywords from './SearchBarRecentKeywords';
......@@ -18,17 +20,20 @@ type Props = {
isHomepage?: boolean;
}
const SCROLL_CONTAINER_ID = 'search_bar_popover_content';
const SearchBar = ({ isHomepage }: Props) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const inputRef = React.useRef<HTMLFormElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null);
const menuWidth = React.useRef<number>(0);
const isMobile = useIsMobile();
const router = useRouter();
const recentSearchKeywords = getRecentSearchKeywords();
const { searchTerm, handleSearchTermChange, query, pathname, redirectCheckQuery } = useSearchQuery();
const { searchTerm, handleSearchTermChange, query, pathname } = useSearchQuery();
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
......@@ -119,15 +124,37 @@ const SearchBar = ({ isHomepage }: Props) => {
value={ searchTerm }
/>
</PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }>
<PopoverBody py={ 6 } sx={ isHomepage ? { mark: { bgColor: 'green.100' } } : {} } color="chakra-body-text">
{ searchTerm.trim().length === 0 && recentSearchKeywords.length > 0 && (
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/>
) }
{ searchTerm.trim().length > 0 && (
<SearchBarSuggest query={ query } redirectCheckQuery={ redirectCheckQuery } searchTerm={ searchTerm } onItemClick={ handleItemClick }/>
) }
<PopoverContent
w={ `${ menuWidth.current }px` }
ref={ menuRef }
>
<PopoverBody py={ 0 } color="chakra-body-text">
<Box
maxH=" 50vh"
overflowY="scroll"
id={ SCROLL_CONTAINER_ID }
ref={ scrollRef }
as={ Element }
sx={ isHomepage ? { mark: { bgColor: 'green.100' } } : {} }
>
{ searchTerm.trim().length === 0 && recentSearchKeywords.length > 0 && (
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/>
) }
{ searchTerm.trim().length > 0 && (
<SearchBarSuggest query={ query } searchTerm={ searchTerm } onItemClick={ handleItemClick } containerId={ SCROLL_CONTAINER_ID }/>
) }
</Box>
</PopoverBody>
{ searchTerm.trim().length > 0 && query.data?.next_page_params && (
<PopoverFooter>
<LinkInternal
href={ route({ pathname: '/search-results', query: { q: searchTerm } }) }
fontSize="sm"
>
View all results
</LinkInternal>
</PopoverFooter>
) }
</PopoverContent>
</Popover>
);
......
......@@ -42,7 +42,7 @@ const SearchBarSuggest = ({ onClick, onClear }: Props) => {
}
return (
<>
<Box py={ 6 }>
{ !isMobile && (
<Box pb={ 4 } mb={ 5 } borderColor="divider" borderBottomWidth="1px" _empty={{ display: 'none' }}>
<TextAd/>
......@@ -82,7 +82,7 @@ const SearchBarSuggest = ({ onClick, onClear }: Props) => {
</Flex>
</Box>
)) }
</>
</Box>
);
};
......
import { Box, Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import _uniqBy from 'lodash/uniqBy';
import { Box, Tab, TabList, Tabs, Text, useColorModeValue } from '@chakra-ui/react';
import throttle from 'lodash/throttle';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { SearchRedirectResult, SearchResultItem } from 'types/api/search';
import type { SearchResultItem } from 'types/api/search';
import useIsMobile from 'lib/hooks/useIsMobile';
import TextAd from 'ui/shared/ad/TextAd';
......@@ -12,97 +12,220 @@ import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPage
import SearchBarSuggestItem from './SearchBarSuggestItem';
const getUniqueIdentifier = (item: SearchResultItem) => {
type Category = 'token' | 'nft' | 'address' | 'app' | 'public_tag' | 'transaction' | 'block';
const CATEGORIES: Array<{id: Category; title: string }> = [
{ id: 'token', title: 'Tokens' },
// { id: 'tokens', title: 'Tokens (ERC-20)' },
// { id: 'nfts', title: 'NFTs (ERC-721 & ERC-1155)' },
{ id: 'address', title: 'Addresses' },
{ id: 'app', title: 'Apps' },
{ id: 'public_tag', title: 'Public tags' },
{ id: 'transaction', title: 'Transactions' },
{ id: 'block', title: 'Blocks' },
];
const getItemCategory = (item: SearchResultItem): Category | undefined => {
switch (item.type) {
case 'contract':
case 'address': {
return item.type + item.address;
case 'address':
case 'contract': {
return 'address';
}
case 'transaction': {
return item.type + item.tx_hash;
case 'token': {
return 'token';
}
case 'block': {
return item.type + (item.block_hash || item.block_number);
return 'block';
}
case 'token': {
return item.type + item.address;
case 'label': {
return 'public_tag';
}
case 'transaction': {
return 'transaction';
}
}
};
interface Props {
query: QueryWithPagesResult<'search'>;
redirectCheckQuery: UseQueryResult<SearchRedirectResult>;
searchTerm: string;
onItemClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
containerId: string;
}
const SearchBarSuggest = ({ query, redirectCheckQuery, searchTerm, onItemClick }: Props) => {
// eslint-disable-next-line import-helpers/order-imports
// import * as searchMock from 'mocks/search/index';
// const mock = [
// searchMock.address1,
// searchMock.block1,
// searchMock.contract1,
// searchMock.label1,
// searchMock.token1,
// searchMock.token2,
// searchMock.tx1,
// searchMock.address1,
// searchMock.block1,
// searchMock.block1,
// searchMock.block1,
// searchMock.block1,
// searchMock.block1,
// searchMock.block1,
// searchMock.block1,
// searchMock.block1,
// searchMock.block1,
// searchMock.contract1,
// searchMock.label1,
// searchMock.token1,
// searchMock.token2,
// searchMock.tx1,
// searchMock.address1,
// searchMock.block1,
// searchMock.contract1,
// searchMock.label1,
// searchMock.token1,
// searchMock.token2,
// searchMock.tx1,
// searchMock.address1,
// searchMock.block1,
// searchMock.contract1,
// searchMock.label1,
// searchMock.token1,
// searchMock.token2,
// searchMock.tx1,
// searchMock.address1,
// searchMock.block1,
// searchMock.contract1,
// searchMock.label1,
// searchMock.token1,
// searchMock.token2,
// searchMock.tx1,
// ];
const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props) => {
const isMobile = useIsMobile();
const simpleMatch: SearchResultItem | undefined = React.useMemo(() => {
if (!redirectCheckQuery.data || !redirectCheckQuery.data.redirect || !redirectCheckQuery.data.parameter) {
const categoriesRefs = React.useRef<Array<HTMLParagraphElement>>([]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const [ tabIndex, setTabIndex ] = React.useState(0);
const handleScroll = React.useCallback(() => {
const container = document.getElementById(containerId);
if (!container || !query.data?.items.length) {
return;
}
switch (redirectCheckQuery.data?.type) {
case 'address': {
return {
type: 'address',
name: '',
address: redirectCheckQuery.data.parameter,
};
const topLimit = container.getBoundingClientRect().y + (tabsRef.current?.clientHeight || 0) + 24;
if (categoriesRefs.current[categoriesRefs.current.length - 1].getBoundingClientRect().y <= topLimit) {
setTabIndex(categoriesRefs.current.length - 1);
return;
}
for (let i = 0; i < categoriesRefs.current.length - 1; i++) {
if (categoriesRefs.current[i].getBoundingClientRect().y <= topLimit && categoriesRefs.current[i + 1].getBoundingClientRect().y > topLimit) {
setTabIndex(i);
break;
}
case 'transaction': {
return {
type: 'transaction',
tx_hash: redirectCheckQuery.data.parameter,
};
}
}, [ containerId, query.data?.items ]);
React.useEffect(() => {
const container = document.getElementById(containerId);
const throttledHandleScroll = throttle(handleScroll, 300);
if (container) {
container.addEventListener('scroll', throttledHandleScroll);
}
return () => {
if (container) {
container.removeEventListener('scroll', throttledHandleScroll);
}
};
}, [ containerId, handleScroll ]);
const itemsGroups = React.useMemo(() => {
if (!query.data?.items) {
return {};
}
}, [ redirectCheckQuery.data ]);
const items = React.useMemo(() => {
return _uniqBy(
[
simpleMatch,
...(query.data?.items || []),
].filter(Boolean),
getUniqueIdentifier,
);
}, [ query.data?.items, simpleMatch ]);
const map: Partial<Record<Category, Array<SearchResultItem>>> = {};
query.data?.items.forEach(item => {
// mock.forEach(item => {
const cat = getItemCategory(item);
if (cat) {
if (cat in map) {
map[cat]?.push(item);
} else {
map[cat] = [ item ];
}
}
});
return map;
}, [ query.data?.items ]);
const scrollToCategory = React.useCallback((index: number) => {
setTabIndex(index);
scroller.scrollTo(`cat_${ index }`, {
duration: 250,
smooth: true,
offset: -(tabsRef.current?.clientHeight || 0),
containerId: containerId,
});
}, [ containerId ]);
const bgColor = useColorModeValue('white', 'gray.900');
const content = (() => {
if (query.isLoading && !simpleMatch) {
return <ContentLoader text="We are searching, please wait... " fontSize="sm"/>;
if (query.isLoading) {
return <ContentLoader text="We are searching, please wait... " fontSize="sm" my={ 5 }/>;
}
if (query.isError && !simpleMatch) {
if (query.isError) {
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
}
const num = query.data?.next_page_params ? '50+' : items.length;
const resultText = items.length > 1 || query.pagination.page > 1 ? 'results' : 'result';
const resultCategories = CATEGORIES.filter(cat => itemsGroups[cat.id]);
return (
<>
<Text fontWeight={ 500 } fontSize="sm">Found <Text fontWeight={ 700 } as="span">{ num }</Text> matching { resultText }</Text>
{ items.map((item, index) =>
<SearchBarSuggestItem key={ index } data={ item } isMobile={ isMobile } searchTerm={ searchTerm } onClick={ onItemClick }/>) }
{ query.isLoading && <ContentLoader text="We are still searching, please wait... " fontSize="sm" mt={ 5 }/> }
{ resultCategories.length > 1 && (
<Box position="sticky" top="0" width="100%" background={ bgColor } py={ 5 } my={ -5 } ref={ tabsRef }>
<Tabs variant="outline" colorScheme="gray" size="sm" onChange={ scrollToCategory } index={ tabIndex }>
<TabList columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
{ resultCategories.map(cat => <Tab key={ cat.id }>{ cat.title }</Tab>) }
</TabList>
</Tabs>
</Box>
) }
{ resultCategories.map((cat, indx) => {
return (
<Element name={ `cat_${ indx }` } key={ indx }>
<Text
fontSize="sm"
fontWeight={ 600 }
variant="secondary"
mt={ 6 }
mb={ 3 }
ref={ (el: HTMLParagraphElement) => categoriesRefs.current[indx] = el }
>
{ cat.title }
</Text>
{ itemsGroups[cat.id]?.map((item, index) =>
<SearchBarSuggestItem key={ index } data={ item } isMobile={ isMobile } searchTerm={ searchTerm } onClick={ onItemClick }/>,
) }
</Element>
);
}) }
</>
);
})();
return (
<>
<Box mt={ 5 } mb={ 5 }>
{ !isMobile && (
<Box pb={ 4 } mb={ 5 } borderColor="divider" borderBottomWidth="1px" _empty={{ display: 'none' }}>
<TextAd/>
</Box>
) }
{ content }
</>
</Box>
);
};
......
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