feat: Implemented tag search, added search hints, reordered filters

Signed-off-by: Raul Kele <raulkeleblk@gmail.com>

Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
Raul Kele 2022-10-19 13:11:32 +03:00
parent 634ab073fb
commit 4d339506b2
5 changed files with 159 additions and 31 deletions

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import SearchSuggestion from 'components/SearchSuggestion';
@ -54,6 +54,12 @@ const mockImageList = {
Labels: ''
}
}
],
Images: [
{
RepoName: 'debian',
Tag: 'testTag'
}
]
}
};
@ -73,4 +79,46 @@ describe('Search component', () => {
userEvent.type(searchInput, 'test');
expect(await screen.findByText(/alpine/i)).toBeInTheDocument();
});
it('should navigate to repo page when a repo suggestion is clicked', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<SearchSuggestion />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'test');
const suggestionItemRepo = await screen.findByText(/alpine/i);
userEvent.click(suggestionItemRepo);
await waitFor(() => expect(mockedUsedNavigate).toHaveBeenCalledWith('/image/alpine'));
});
it('should navigate to repo page when a image suggestion is clicked', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<SearchSuggestion />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'debian:test');
const suggestionItemImage = await screen.findByText(/debian:testTag/i);
userEvent.click(suggestionItemImage);
await waitFor(() => expect(mockedUsedNavigate).toHaveBeenCalledWith('/image/debian/tag/testTag'));
});
it('should log an error if it doesnt receive an ok response for repo search', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<SearchSuggestion />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'debian');
await waitFor(() => expect(error).toBeCalledTimes(1));
});
it('should log an error if it doesnt receive an ok response for image search', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<SearchSuggestion />);
const searchInput = screen.getByPlaceholderText(/search for content/i);
userEvent.type(searchInput, 'debian:test');
await waitFor(() => expect(error).toBeCalledTimes(1));
});
});

View File

@ -88,6 +88,11 @@ const endpoints = {
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 Description IsSigned Logo Licenses Vendor Labels } DownloadCount}}}`;
},
imageSuggestions: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
const paginationParam = `requestedPage: {limit:${pageSize} offset:${(pageNumber - 1) * pageSize} sortBy:RELEVANCE}`;
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag Logo}}}`;
}
};

View File

@ -124,12 +124,6 @@ function Explore() {
const renderFilterCards = () => {
return (
<Stack spacing={2}>
<FilterCard
title="Images"
filters={filterConstants.imageFilters}
filterValue={imageFilters}
updateFilters={setImageFilters}
/>
<FilterCard
title="Operating system"
filters={filterConstants.osFilters}
@ -142,6 +136,12 @@ function Explore() {
filterValue={archFilters}
updateFilters={setArchFilters}
/>
<FilterCard
title="Additional filters"
filters={filterConstants.imageFilters}
filterValue={imageFilters}
updateFilters={setImageFilters}
/>
</Stack>
);
};

View File

@ -5,7 +5,7 @@ import SearchIcon from '@mui/icons-material/Search';
import React, { useEffect, useMemo, useState } from 'react';
import { api, endpoints } from 'api';
import { host } from 'host';
import { mapToRepo } from 'utilities/objectModels';
import { mapToImage, mapToRepo } from 'utilities/objectModels';
import { createSearchParams, useNavigate } from 'react-router-dom';
import { debounce, isEmpty } from 'lodash';
import { useCombobox } from 'downshift';
@ -103,7 +103,12 @@ function SearchSuggestion() {
const handleSuggestionSelected = (event) => {
const name = event.selectedItem?.name;
navigate(`/image/${encodeURIComponent(name)}`);
if (name?.includes(':')) {
const splitName = name.split(':');
navigate(`/image/${encodeURIComponent(splitName[0])}/tag/${splitName[1]}`);
} else {
navigate(`/image/${encodeURIComponent(name)}`);
}
};
const handleSearch = (event) => {
@ -113,31 +118,65 @@ function SearchSuggestion() {
}
};
const repoSearch = (value) => {
api
.get(
`${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`,
abortController.signal
)
.then((suggestionResponse) => {
if (suggestionResponse.data.data.GlobalSearch.Repos) {
const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Repos.map((el) => mapToRepo(el));
setSuggestionData(suggestionParsedData);
if (isEmpty(suggestionParsedData)) {
setIsFailedSearch(true);
}
}
setIsLoading(false);
})
.catch((e) => {
console.error(e);
setIsLoading(false);
setIsFailedSearch(true);
});
};
const imageSearch = (value) => {
api
.get(
`${host()}${endpoints.imageSuggestions({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`,
abortController.signal
)
.then((suggestionResponse) => {
if (suggestionResponse.data.data.GlobalSearch.Images) {
const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Images.map((el) => mapToImage(el));
setSuggestionData(suggestionParsedData);
if (isEmpty(suggestionParsedData)) {
setIsFailedSearch(true);
}
}
setIsLoading(false);
})
.catch((e) => {
console.error(e);
setIsLoading(false);
setIsFailedSearch(true);
});
};
const handleSeachChange = (event) => {
const value = event.inputValue;
setSearchQuery(value);
setIsFailedSearch(false);
if (value !== '' && value.length > 1) {
setSuggestionData([]);
if (value !== '') {
setIsLoading(true);
api
.get(
`${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`,
abortController.signal
)
.then((suggestionResponse) => {
if (suggestionResponse.data.data.GlobalSearch.Repos) {
const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Repos.map((el) => mapToRepo(el));
setSuggestionData(suggestionParsedData);
if (isEmpty(suggestionParsedData)) {
setIsFailedSearch(true);
}
}
setIsLoading(false);
})
.catch((e) => {
console.error(e);
setIsLoading(false);
});
// if search term inclused the ':' character, search for images, if not, search repos
if (value?.includes(':')) {
imageSearch(value);
} else {
repoSearch(value);
}
}
};
@ -219,9 +258,35 @@ function SearchSuggestion() {
</Stack>
<List
{...getMenuProps()}
className={isOpen && suggestionData?.length > 0 ? classes.resultsWrapper : classes.resultsWrapperHidden}
className={isOpen && !isLoading ? classes.resultsWrapper : classes.resultsWrapperHidden}
>
{isOpen && suggestionData?.length > 0 && renderSuggestions()}
{isOpen && isEmpty(searchQuery) && (
<>
<ListItem
className={classes.searchItem}
style={{ color: '#52637A', fontSize: '1rem', textOverflow: 'ellipsis' }}
{...getItemProps({ item: '', index: 0 })}
spacing={2}
onClick={() => {}}
>
<Stack direction="row" spacing={2}>
<Typography>Press Enter for advanced search</Typography>
</Stack>
</ListItem>
<ListItem
className={classes.searchItem}
style={{ color: '#52637A', fontSize: '1rem', textOverflow: 'ellipsis' }}
{...getItemProps({ item: '', index: 0 })}
spacing={2}
onClick={() => {}}
>
<Stack direction="row" spacing={2}>
<Typography>Use the &apos;:&apos; character to search for tags</Typography>
</Stack>
</ListItem>
</>
)}
</List>
</div>
);

View File

@ -15,4 +15,14 @@ const mapToRepo = (responseRepo) => {
};
};
export { mapToRepo };
const mapToImage = (responseImage) => {
return {
repoName: responseImage.RepoName,
tag: responseImage.Tag,
// frontend only prop to increase interop with Repo objects and code reusability
name: `${responseImage.RepoName}:${responseImage.Tag}`,
logo: responseImage.Logo
};
};
export { mapToRepo, mapToImage };