feat(explore): Implemented filter feature (#104)

feat: Refactored global search, implemented filter feature
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
Raul Kele 2022-10-07 11:41:48 +03:00 committed by GitHub
parent ca1c9b00cc
commit c93447b0e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 246 additions and 150 deletions

View File

@ -27,9 +27,9 @@ function App() {
<Route element={<AuthWrapper isLoggedIn={isLoggedIn} redirect="/login" />}>
<Route path="/" element={<Navigate to="/home" />} />
<Route path="/home" element={<HomePage data={data} updateData={setData} />} />
<Route path="/explore" element={<ExplorePage data={data} updateData={setData} />} />
<Route path="/image/:name" element={<RepoPage updateData={setData} />} />
<Route path="/image/:name/tag/:tag" element={<TagPage updateData={setData} />} />
<Route path="/explore" element={<ExplorePage />} />
<Route path="/image/:name" element={<RepoPage />} />
<Route path="/image/:name/tag/:tag" element={<TagPage />} />
</Route>
<Route element={<AuthWrapper isLoggedIn={!isLoggedIn} redirect="/" />}>
<Route

View File

@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import Explore from 'components/Explore';
import React, { useState } from 'react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
// router mock
@ -13,53 +13,54 @@ jest.mock('react-router-dom', () => ({
}));
const StateExploreWrapper = (props) => {
const [data, useData] = useState([]);
const queryString = props.search || '';
return (
<MemoryRouter initialEntries={[queryString]}>
<Explore data={data} updateData={useData} />
<Explore />
</MemoryRouter>
);
};
const mockImageList = {
RepoListWithNewestImage: [
{
Name: 'alpine',
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
NewestImage: {
Tag: 'latest',
Description: 'w',
Licenses: '',
Vendor: '',
Labels: ''
GlobalSearch: {
Repos: [
{
Name: 'alpine',
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
NewestImage: {
Tag: 'latest',
Description: 'w',
Licenses: '',
Vendor: '',
Labels: ''
}
},
{
Name: 'mongo',
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
NewestImage: {
Tag: 'latest',
Description: '',
Licenses: '',
Vendor: '',
Labels: ''
}
},
{
Name: 'nodeUnique',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
Licenses: '',
Vendor: '',
Labels: ''
}
}
},
{
Name: 'mongo',
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
NewestImage: {
Tag: 'latest',
Description: '',
Licenses: '',
Vendor: '',
Labels: ''
}
},
{
Name: 'nodeUnique',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
Licenses: '',
Vendor: '',
Labels: ''
}
}
]
]
}
};
afterEach(() => {
// restore the spy created with spyOn

View File

@ -1,9 +1,10 @@
import { render, screen, fireEvent } from '@testing-library/react';
import FilterCard from 'components/FilterCard';
import React from 'react';
import filterConstants from 'utilities/filterConstants';
const StateFilterCardWrapper = () => {
return <FilterCard title="Products" filters={['Images', 'Plugins']} />;
return <FilterCard title="Products" filters={filterConstants.osFilters} updateFilters={() => {}} />;
};
describe('Filters components', () => {

View File

@ -29,7 +29,7 @@ it('renders the repository page component', () => {
render(
<BrowserRouter>
<Routes>
<Route path="*" element={<RepoPage updateData={() => {}} />} />
<Route path="*" element={<RepoPage />} />
</Routes>
</BrowserRouter>
);

View File

@ -20,7 +20,7 @@ it('renders the tags page component', async () => {
render(
<BrowserRouter>
<Routes>
<Route path="*" element={<TagPage updateData={() => {}} />} />
<Route path="*" element={<TagPage />} />
</Routes>
</BrowserRouter>
);

View File

@ -1,3 +1,4 @@
// @ts-nocheck
import axios from 'axios';
import { isEmpty } from 'lodash';
@ -65,16 +66,21 @@ const endpoints = {
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Digest Tag Layers {Size Digest}} Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName Layers {Size Digest} Digest Tag Title Documentation DownloadCount Source Description History {Layer {Size Digest} HistoryDescription {Created CreatedBy Author Comment EmptyLayer}}}}}}`,
vulnerabilitiesForRepo: (name) =>
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag, CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`,
globalSearch: (searchQuery) =>
`/v2/_zot/ext/search?query={GlobalSearch(query:"${searchQuery}") {Repos {Name LastUpdated Size Platforms { Os Arch } NewestImage { Tag Description Licenses Vendor Labels } DownloadCount}}}`,
layersDetailsForImage: (name) =>
`/v2/_zot/ext/search?query={Image(image: "${name}"){History {Layer {Size Digest Score} HistoryDescription {Created CreatedBy Author Comment EmptyLayer} }}}`,
dependsOnForImage: (name) => `/v2/_zot/ext/search?query={BaseImageList(image: "${name}"){RepoName}}`,
isDependentOnForImage: (name) => `/v2/_zot/ext/search?query={DerivedImageList(image: "${name}"){RepoName}}`,
globalSearchPaginated: (searchQuery, pageNumber, pageSize) =>
`/v2/_zot/ext/search?query={GlobalSearch(query:"${searchQuery}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}) {Repos {Name LastUpdated Size Platforms { Os Arch } NewestImage { Tag Description Licenses Vendor Labels } DownloadCount}}}`
globalSearch: ({ searchQuery = '""', pageNumber = 1, pageSize = 15, filter = {} }) => {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
const paginationParam = `requestedPage: {limit:${pageSize} offset:${(pageNumber - 1) * pageSize}}`;
let filterParam = `,filter: {`;
if (filter.Os) filterParam += ` Os:${!isEmpty(filter.Os) ? `"${filter.Os}"` : '""'}`;
if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `"${filter.Arch}"` : '""'}`;
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 Description Licenses Vendor Labels } DownloadCount}}}`;
}
};
export { api, endpoints };

View File

@ -15,6 +15,9 @@ import { api, endpoints } from '../api';
import { host } from '../host';
import { mapToRepo } from 'utilities/objectModels.js';
import { useSearchParams } from 'react-router-dom';
import FilterCard from './FilterCard.jsx';
import { isEmpty } from 'lodash';
import filterConstants from 'utilities/filterConstants.js';
const useStyles = makeStyles(() => ({
gridWrapper: {
@ -45,42 +48,51 @@ const useStyles = makeStyles(() => ({
}
}));
function Explore({ data, updateData }) {
function Explore() {
const [isLoading, setIsLoading] = useState(true);
const [exploreData, setExploreData] = useState([]);
// const [sortFilter, setSortFilter] = useState('');
const [queryParams] = useSearchParams();
const search = queryParams.get('search');
// filtercard filters
const [imageFilters, setImageFilters] = useState(false);
const [osFilters, setOSFilters] = useState('');
const [archFilters, setArchFilters] = useState('');
const classes = useStyles();
useEffect(() => {
if (!queryParams.get('search')) {
api
.get(`${host()}${endpoints.repoList}`)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.RepoListWithNewestImage;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
updateData(repoData);
setIsLoading(false);
}
})
.catch((e) => {
console.error(e);
});
const buildFilterQuery = () => {
let filter = {};
// workaround until backend bugfix
filter = !isEmpty(osFilters) ? { ...filter, Os: osFilters.toLocaleLowerCase() } : filter;
filter = !isEmpty(archFilters) ? { ...filter, Arch: archFilters.toLocaleLowerCase() } : filter;
if (imageFilters) {
filter = { ...filter, HasToBeSigned: imageFilters };
}
}, [updateData, queryParams]);
return filter;
};
useEffect(() => {
api
.get(`${host()}${endpoints.globalSearch({ searchQuery: search, filter: buildFilterQuery() })}`)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.GlobalSearch.Repos;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
setExploreData(repoData);
setIsLoading(false);
}
})
.catch((e) => {
console.error(e);
});
}, [search, queryParams, imageFilters, osFilters, archFilters]);
useEffect(() => {
setIsLoading(false);
}, []);
// Update component data based on response data, here filtering logic will be added
useEffect(() => {
setExploreData(data);
}, [data]);
const renderRepoCards = () => {
return (
exploreData &&
@ -105,19 +117,30 @@ function Explore({ data, updateData }) {
);
};
// const renderFilterCards = () => {
// return (
// <Stack spacing={2}>
// <FilterCard title="Products" filters={['Images', 'Plugins']} />
// <FilterCard title="Images" filters={['Verified publisher', 'Official images']} />
// <FilterCard title="Operating system" filters={['Windows', 'Linux']} />
// <FilterCard
// title="Architectures"
// filters={['ARM', 'ARM 64', 'IBM POWER', 'IBM Z', 'PowerPC 64 LE', 'x86', 'x86-64']}
// />
// </Stack>
// );
// };
const renderFilterCards = () => {
return (
<Stack spacing={2}>
<FilterCard
title="Images"
filters={filterConstants.imageFilters}
filterValue={imageFilters}
updateFilters={setImageFilters}
/>
<FilterCard
title="Operating system"
filters={filterConstants.osFilters}
filterValue={osFilters}
updateFilters={setOSFilters}
/>
<FilterCard
title="Architectures"
filters={filterConstants.archFilters}
filterValue={archFilters}
updateFilters={setArchFilters}
/>
</Stack>
);
};
// const handleSortChange = (event) => {
// setSortFilter(event.target.value);
@ -126,46 +149,46 @@ function Explore({ data, updateData }) {
return (
<Container maxWidth="lg">
{isLoading && <Loading />}
{!(exploreData && exploreData.length) ? (
<Grid container className={classes.nodataWrapper}>
<div style={{ marginTop: 20 }}>
<div style={{}}>
<Alert style={{ marginTop: 10, width: '100%' }} variant="outlined" severity="warning">
Looks like we don&apos;t have anything matching that search. Try searching something else.
</Alert>
</div>
</div>
</Grid>
) : (
<Grid container className={classes.gridWrapper}>
<Grid container item xs={12}>
<Grid item xs={0}></Grid>
<Grid item xs={12}>
<Stack direction="row" className={classes.resultsRow}>
<Typography variant="body2" className={classes.results}>
Results {exploreData.length}
</Typography>
{/* <FormControl sx={{m:'1', minWidth:"4.6875rem"}} className={classes.sortForm} size="small">
<Grid container className={classes.gridWrapper}>
<Grid container item xs={12}>
<Grid item xs={0}></Grid>
<Grid item xs={12}>
<Stack direction="row" className={classes.resultsRow}>
<Typography variant="body2" className={classes.results}>
Results {exploreData.length}
</Typography>
{/* <FormControl sx={{m:'1', minWidth:"4.6875rem"}} className={classes.sortForm} size="small">
<InputLabel>Sort</InputLabel>
<Select label="Sort" value={sortFilter} onChange={handleSortChange} MenuProps={{disableScrollLock: true}}>
<MenuItem value='relevance'>Relevance</MenuItem>
</Select>
</FormControl> */}
</Stack>
</Grid>
</Stack>
</Grid>
<Grid container item xs={12} spacing={5} pt={1}>
{/* <Grid item xs={3}>
{renderFilterCards()}
</Grid> */}
<Grid item xs={12}>
</Grid>
<Grid container item xs={12} spacing={5} pt={1}>
<Grid item xs={3}>
{renderFilterCards()}
</Grid>
<Grid item xs={9}>
{!(exploreData && exploreData.length) ? (
<Grid container className={classes.nodataWrapper}>
<div style={{ marginTop: 20 }}>
<div style={{}}>
<Alert style={{ marginTop: 10, width: '100%' }} variant="outlined" severity="warning">
Looks like we don&apos;t have anything matching that search. Try searching something else.
</Alert>
</div>
</div>
</Grid>
) : (
<Stack direction="column" spacing={2}>
{renderRepoCards()}
</Stack>
</Grid>
)}
</Grid>
</Grid>
)}
</Grid>
</Container>
);
}

View File

@ -1,6 +1,6 @@
import { Card, CardContent, Checkbox, FormControlLabel, Stack, Typography } from '@mui/material';
import { makeStyles } from '@mui/styles';
import React from 'react';
import React, { useState } from 'react';
const useStyles = makeStyles(() => ({
card: {
@ -17,17 +17,43 @@ const useStyles = makeStyles(() => ({
function FilterCard(props) {
const classes = useStyles();
const { title, filters } = props;
const { title, filters, updateFilters } = props;
const [selectedFilter, setSelectedFilter] = useState(null);
const handleFilterClicked = (event, changedFilterLabel, changedFilterValue) => {
const { checked } = event.target;
if (checked) {
// updateFilters([...filterValue, changedFilterValue]);
if (filters[0]?.type === 'boolean') {
updateFilters(checked);
} else {
updateFilters(changedFilterValue);
}
setSelectedFilter(changedFilterLabel);
} else {
// updateFilters(filterValue.filter((e) => e !== changedFilterValue));
if (filters[0]?.type === 'boolean') {
updateFilters(checked);
} else {
updateFilters('');
}
setSelectedFilter(null);
}
};
const getFilterRows = () => {
const filterRows = filters || ['ARM', 'ARM 64', 'IBM POWER', 'IBM Z', 'PowerPC 64 LE', 'x86', 'x86-64'];
const filterRows = filters;
return filterRows.map((filter, index) => {
return (
<FormControlLabel
key={index}
componentsProps={{ typography: { variant: 'body2' } }}
control={<Checkbox />}
label={filter}
label={filter.label}
id={title}
checked={filter.label === selectedFilter}
onChange={() => handleFilterClicked(event, filter.label, filter.value)}
/>
);
});

View File

@ -68,7 +68,7 @@ const useStyles = makeStyles(() => ({
}
}));
function Header({ updateData }) {
function Header() {
const classes = useStyles();
const path = useLocation().pathname;
// const navigate = useNavigate();
@ -97,7 +97,7 @@ function Header({ updateData }) {
<Link to="/home" className={classes.logoWrapper}>
<Avatar alt="zot" src={logo} className={classes.logo} variant="square" />
</Link>
{path !== '/' && <SearchSuggestion updateData={updateData} />}
{path !== '/' && <SearchSuggestion />}
<div></div>
{/* <IconButton
ref={anchorRef}

View File

@ -171,7 +171,7 @@ function RepoCard(props) {
{signatureCheck()} */}
{/* <Chip label="Verified licensee" sx={{ backgroundColor: "#E8F5E9", color: "#388E3C" }} variant="filled" onDelete={() => { return }} deleteIcon={vulnerabilityCheck()} /> */}
</Stack>
<Typography className={classes.versionLast} pt={1} sx={{ fontSize: 12 }} gutterBottom>
<Typography className={classes.versionLast} pt={1} sx={{ fontSize: 12 }} gutterBottom noWrap>
{description || 'N/A'}
</Typography>
<Stack alignItems="center" direction="row" spacing={2} pt={1}>

View File

@ -71,7 +71,7 @@ const useStyles = makeStyles(() => ({
}
}));
function SearchSuggestion({ updateData }) {
function SearchSuggestion() {
const [searchQuery, setSearchQuery] = useState('');
const [suggestionData, setSuggestionData] = useState([]);
const navigate = useNavigate();
@ -85,21 +85,7 @@ function SearchSuggestion({ updateData }) {
const handleSearch = (event) => {
const { key, type } = event;
if (key === 'Enter' || type === 'click') {
api
.get(`${host()}${endpoints.globalSearch(searchQuery)}`)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.GlobalSearch.Repos;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
updateData(repoData);
navigate({ pathname: `/explore`, search: createSearchParams({ search: searchQuery }).toString() });
}
})
.catch((e) => {
console.error(e);
});
navigate({ pathname: `/explore`, search: createSearchParams({ search: searchQuery }).toString() });
}
};
@ -108,7 +94,7 @@ function SearchSuggestion({ updateData }) {
setSearchQuery(value);
if (value !== '' && value.length > 1) {
api
.get(`${host()}${endpoints.globalSearchPaginated(value, 1, 9)}`)
.get(`${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`)
.then((suggestionResponse) => {
if (suggestionResponse.data.data.GlobalSearch.Repos) {
const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Repos.map((el) => mapToRepo(el));

View File

@ -25,16 +25,16 @@ const useStyles = makeStyles(() => ({
}
}));
function ExplorePage({ data, updateData }) {
function ExplorePage() {
const classes = useStyles();
return (
<Stack className={classes.pageWrapper} direction="column" data-testid="explore-container">
<Header updateData={updateData} />
<Header />
<Container className={classes.container}>
<Grid container className={classes.gridWrapper}>
<Grid item className={classes.tile}>
<Explore data={data} updateData={updateData} />
<Explore />
</Grid>
</Grid>
</Container>

View File

@ -30,7 +30,7 @@ function HomePage({ data, updateData }) {
return (
<Stack className={classes.pageWrapper} direction="column" data-testid="homepage-container">
<Header updateData={updateData} />
<Header />
<Container className={classes.container}>
<Grid container className={classes.gridWrapper}>
<Grid item className={classes.tile}>

View File

@ -29,12 +29,12 @@ const useStyles = makeStyles(() => ({
}
}));
function RepoPage({ updateData }) {
function RepoPage() {
const classes = useStyles();
return (
<Stack direction="column" className={classes.pageWrapper} data-testid="repo-container">
<Header updateData={updateData} />
<Header />
<Container className={classes.container}>
<ExploreHeader />
<Grid container className={classes.gridWrapper}>

View File

@ -29,12 +29,12 @@ const useStyles = makeStyles(() => ({
}
}));
function TagPage({ updateData }) {
function TagPage() {
const classes = useStyles();
return (
<Stack direction="column" className={classes.pageWrapper} data-testid="tag-container">
<Header updateData={updateData} />
<Header />
<Container className={classes.container}>
<ExploreHeader />
<Grid container className={classes.gridWrapper}>

View File

@ -0,0 +1,53 @@
const osFilters = [
{
label: 'Windows',
value: 'windows'
},
{
label: 'Linux',
value: 'linux'
}
];
const imageFilters = [
{
label: 'Signed Images',
value: 'HasToBeSigned',
type: 'boolean'
}
];
const archFilters = [
{
label: 'ARM',
value: 'arm'
},
{
label: 'ARM 64',
value: 'arm64'
},
{
label: 'IBM POWER',
value: 'ppc64'
},
{
label: 'IBM Z',
value: 's390x'
},
{
label: 'PowerPC 64 LE',
value: 'ppc64le'
},
{
label: 'x86',
value: '386'
},
{
label: 'x86-64',
value: 'amd64'
}
];
const filterConstants = { osFilters, imageFilters, archFilters };
export default filterConstants;