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:
parent
634ab073fb
commit
4d339506b2
@ -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));
|
||||
});
|
||||
});
|
||||
|
@ -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}}}`;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 ':' character to search for tags</Typography>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
|
@ -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 };
|
||||
|
Loading…
Reference in New Issue
Block a user