feat: Implemented pagination on explore page.

fix: Fixed persistent search suggestion value
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>

Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
Raul Kele 2022-11-02 13:05:55 +02:00
parent 33dcc936bb
commit 52c1559c62
8 changed files with 235 additions and 129 deletions

View File

@ -22,6 +22,7 @@ const StateExploreWrapper = (props) => {
};
const mockImageList = {
GlobalSearch: {
Page: { TotalCount: 20, ItemCount: 10 },
Repos: [
{
Name: 'alpine',
@ -128,6 +129,18 @@ const mockImageList = {
]
}
};
beforeEach(() => {
// IntersectionObserver isn't available in test environment
const mockIntersectionObserver = jest.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null
});
window.IntersectionObserver = mockIntersectionObserver;
});
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();

View File

@ -11,110 +11,112 @@ jest.mock('react-router-dom', () => ({
}));
const mockImageList = {
RepoListWithNewestImage: [
{
Name: 'alpine',
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 7
RepoListWithNewestImage: {
Results: [
{
Name: 'alpine',
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 7
}
}
},
{
Name: 'mongo',
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 2
}
}
},
{
Name: 'node',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 10
}
}
},
{
Name: 'centos',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'NONE',
Count: 10
}
}
},
{
Name: 'debian',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'MEDIUM',
Count: 10
}
}
},
{
Name: 'mysql',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'UNKNOWN',
Count: 10
}
}
}
},
{
Name: 'mongo',
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 2
}
}
},
{
Name: 'node',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 10
}
}
},
{
Name: 'centos',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'NONE',
Count: 10
}
}
},
{
Name: 'debian',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'MEDIUM',
Count: 10
}
}
},
{
Name: 'mysql',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'UNKNOWN',
Count: 10
}
}
}
]
]
}
};
beforeEach(() => {
@ -139,7 +141,7 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<Home />);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(3);
});
it('renders vulnerability icons', async () => {
@ -148,7 +150,6 @@ describe('Home component', () => {
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1);
expect(await screen.findAllByTestId('none-vulnerability-icon')).toHaveLength(1);
});
it("should log an error when data can't be fetched", async () => {

View File

@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import SearchSuggestion from 'components/SearchSuggestion';
import { MemoryRouter } from 'react-router-dom';
import React from 'react';
// router mock
@ -11,6 +12,15 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate
}));
const RouterSearchWrapper = (props) => {
const queryString = props.search || '';
return (
<MemoryRouter initialEntries={[queryString]}>
<SearchSuggestion />
</MemoryRouter>
);
};
const mockImageList = {
GlobalSearch: {
Repos: [
@ -71,7 +81,7 @@ afterEach(() => {
describe('Search component', () => {
it('should display suggestions when user searches', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<SearchSuggestion />);
render(<RouterSearchWrapper />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
expect(searchInput).toBeInTheDocument();
userEvent.type(searchInput, 'test');
@ -80,7 +90,7 @@ describe('Search component', () => {
it('should navigate to repo page when a repo suggestion is clicked', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<SearchSuggestion />);
render(<RouterSearchWrapper />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'test');
const suggestionItemRepo = await screen.findByText(/alpine/i);
@ -90,7 +100,7 @@ describe('Search component', () => {
it('should navigate to repo page when a image suggestion is clicked', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<SearchSuggestion />);
render(<RouterSearchWrapper />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'debian:test');
const suggestionItemImage = await screen.findByText(/debian:testTag/i);
@ -101,7 +111,7 @@ describe('Search component', () => {
it('should log an error if it doesnt receive an ok response for repo search', async () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<SearchSuggestion />);
render(<RouterSearchWrapper />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'debian');
await waitFor(() => expect(error).toBeCalledTimes(1));
@ -110,7 +120,7 @@ describe('Search component', () => {
it('should log an error if it doesnt receive an ok response for image search', async () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<SearchSuggestion />);
render(<RouterSearchWrapper />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'debian:test');
await waitFor(() => expect(error).toBeCalledTimes(1));

View File

@ -63,7 +63,7 @@ const endpoints = {
repoList: ({ pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}){Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Logo Title Source IsSigned Documentation Vendor Labels} DownloadCount}}`,
}}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Logo Title Source IsSigned Documentation Vendor Labels} DownloadCount}}}`,
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Digest Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor Size Platform {Os Arch}} Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Layers {Size Digest} Digest Tag Logo Title Documentation DownloadCount Source Description Licenses History {Layer {Size Digest} HistoryDescription {Created CreatedBy Author Comment EmptyLayer}}}}}}`,
detailedImageInfo: (name, tag) =>
@ -95,7 +95,7 @@ const endpoints = {
if (filter.HasToBeSigned) filterParam += ` HasToBeSigned: ${filter.HasToBeSigned}`;
filterParam += '}';
if (Object.keys(filter).length === 0) filterParam = '';
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Repos {Name LastUpdated Size Platforms { Os Arch } NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned Logo Licenses Vendor Labels } DownloadCount}}}`;
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Page {TotalCount ItemCount} Repos {Name LastUpdated Size Platforms { Os Arch } NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned Logo Licenses Vendor Labels } DownloadCount}}}`;
},
imageSuggestions: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;

View File

@ -1,5 +1,5 @@
// react global
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
// components
import RepoCard from './RepoCard.jsx';
@ -19,6 +19,7 @@ import FilterCard from './FilterCard.jsx';
import { isEmpty } from 'lodash';
import filterConstants from 'utilities/filterConstants.js';
import { sortByCriteria } from 'utilities/sortCriteria.js';
import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants.js';
const useStyles = makeStyles(() => ({
gridWrapper: {
@ -63,6 +64,11 @@ function Explore() {
const [imageFilters, setImageFilters] = useState(false);
const [osFilters, setOSFilters] = useState([]);
const [archFilters, setArchFilters] = useState([]);
// pagination props
const [pageNumber, setPageNumber] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [isEndOfList, setIsEndOfList] = useState(false);
const listBottom = useRef(null);
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
@ -77,11 +83,17 @@ function Explore() {
return filter;
};
useEffect(() => {
const getPaginatedResults = () => {
setIsLoading(true);
api
.get(
`${host()}${endpoints.globalSearch({ searchQuery: search, sortBy: sortFilter, filter: buildFilterQuery() })}`,
`${host()}${endpoints.globalSearch({
searchQuery: search,
pageNumber,
pageSize: EXPLORE_PAGE_SIZE,
sortBy: sortFilter,
filter: buildFilterQuery()
})}`,
abortController.signal
)
.then((response) => {
@ -90,19 +102,71 @@ function Explore() {
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
setExploreData(repoData);
setTotalItems(response.data.data.GlobalSearch.Page?.TotalCount);
setIsEndOfList(response.data.data.GlobalSearch.Page?.ItemCount < EXPLORE_PAGE_SIZE);
setExploreData((previousState) => [...previousState, ...repoData]);
setIsLoading(false);
}
})
.catch((e) => {
console.error(e);
setIsLoading(false);
setIsEndOfList(true);
});
};
useEffect(() => {
if (isLoading) return;
getPaginatedResults();
return () => {
abortController.abort();
};
}, [pageNumber]);
const resetPagination = () => {
if (pageNumber === 1) {
getPaginatedResults();
} else {
setPageNumber(1);
}
setIsEndOfList(false);
setExploreData([]);
};
// if filters changed, reset pagination and restart lookup
useEffect(() => {
resetPagination();
}, [search, queryParams, imageFilters, osFilters, archFilters, sortFilter]);
const handleSortChange = (event) => {
setSortFilter(event.target.value);
};
// setup intersection obeserver for infinite scroll
useEffect(() => {
if (isLoading || isEndOfList) return;
const handleIntersection = (entries) => {
if (isLoading || isEndOfList) return;
const [target] = entries;
if (target?.isIntersecting) {
setPageNumber((pageNumber) => pageNumber + 1);
}
};
const intersectionObserver = new IntersectionObserver(handleIntersection, {
root: null,
rootMargin: '0px',
threshold: 0
});
if (listBottom.current) {
intersectionObserver.observe(listBottom.current);
}
return () => {
intersectionObserver.disconnect();
};
}, [isLoading, isEndOfList]);
const renderRepoCards = () => {
return (
exploreData &&
@ -154,8 +218,14 @@ function Explore() {
);
};
const handleSortChange = (event) => {
setSortFilter(event.target.value);
const renderListBottom = () => {
if (isLoading) {
return <Loading />;
}
if (!isLoading && !isEndOfList) {
return <div ref={listBottom} />;
}
return '';
};
return (
@ -166,7 +236,7 @@ function Explore() {
<Grid item xs={9}>
<Stack direction="row" className={classes.resultsRow}>
<Typography variant="body2" className={classes.results}>
Results {exploreData.length}
Showing {exploreData?.length} results out of {totalItems}
</Typography>
<FormControl sx={{ m: '1', minWidth: '4.6875rem' }} className={classes.sortForm} size="small">
<InputLabel>Sort</InputLabel>
@ -201,7 +271,8 @@ function Explore() {
</Grid>
) : (
<Stack direction="column" spacing={2}>
{isLoading ? <Loading /> : renderRepoCards()}
{renderRepoCards()}
{renderListBottom()}
</Stack>
)}
</Grid>

View File

@ -73,7 +73,7 @@ function Home() {
.get(`${host()}${endpoints.repoList()}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.RepoListWithNewestImage;
let repoList = response.data.data.RepoListWithNewestImage.Results;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
@ -92,7 +92,7 @@ function Home() {
const renderMostPopular = () => {
return (
homeData &&
homeData.slice(0, 4).map((item, index) => {
homeData.slice(0, 3).map((item, index) => {
return (
<RepoCard
name={item.name}

View File

@ -6,9 +6,10 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api, endpoints } from 'api';
import { host } from 'host';
import { mapToImage, mapToRepo } from 'utilities/objectModels';
import { createSearchParams, useNavigate } from 'react-router-dom';
import { createSearchParams, useNavigate, useSearchParams } from 'react-router-dom';
import { debounce, isEmpty } from 'lodash';
import { useCombobox } from 'downshift';
import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants';
const useStyles = makeStyles(() => ({
searchContainer: {
@ -94,12 +95,16 @@ const useStyles = makeStyles(() => ({
}));
function SearchSuggestion() {
const [searchQuery, setSearchQuery] = useState('');
const [queryParams] = useSearchParams();
const search = queryParams.get('search');
const [searchQuery, setSearchQuery] = useState(search || '');
const [suggestionData, setSuggestionData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [isFailedSearch, setIsFailedSearch] = useState(false);
const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
const handleSuggestionSelected = (event) => {
@ -114,16 +119,15 @@ function SearchSuggestion() {
const handleSearch = (event) => {
const { key, type } = event;
const { value } = event.target;
if (key === 'Enter' || type === 'click') {
navigate({ pathname: `/explore`, search: createSearchParams({ search: value || '' }).toString() });
navigate({ pathname: `/explore`, search: createSearchParams({ search: inputValue || '' }).toString() });
}
};
const repoSearch = (value) => {
api
.get(
`${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`,
`${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: HEADER_SEARCH_PAGE_SIZE })}`,
abortController.signal
)
.then((suggestionResponse) => {
@ -198,10 +202,11 @@ function SearchSuggestion() {
debounceSuggestions.cancel();
abortController.abort();
};
});
}, []);
const {
// selectedItem,
inputValue,
getInputProps,
getMenuProps,
getItemProps,
@ -214,7 +219,8 @@ function SearchSuggestion() {
items: suggestionData,
onInputValueChange: handleSeachChange,
onSelectedItemChange: handleSuggestionSelected,
itemToString: (item) => item.name ?? ''
initialInputValue: search ?? '',
itemToString: (item) => item.name ?? item
});
const renderSuggestions = () => {
@ -255,7 +261,7 @@ function SearchSuggestion() {
>
<InputBase
style={{ paddingLeft: 10, height: 46, color: 'rgba(0, 0, 0, 0.6)' }}
placeholder="Search for content..."
placeholder={'Search for content...'}
className={classes.input}
onKeyUp={handleSearch}
onFocus={() => openMenu()}

View File

@ -0,0 +1,5 @@
const HEADER_SEARCH_PAGE_SIZE = 9;
const EXPLORE_PAGE_SIZE = 10;
const HOME_PAGE_SIZE = 10;
export { HEADER_SEARCH_PAGE_SIZE, EXPLORE_PAGE_SIZE, HOME_PAGE_SIZE };