diff --git a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js index 668a89bb..cb9328d9 100644 --- a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js +++ b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js @@ -1,4 +1,5 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { api } from 'api'; import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails'; import React from 'react'; @@ -432,6 +433,14 @@ const mockCVEList = { } }; +const mockCVEListFiltered = { + CVEListForImage: { + Tag: '', + Page: { ItemCount: 20, TotalCount: 20 }, + CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Id.includes('2022')) + } +}; + const mockCVEFixed = { pageOne: { ImageListWithCVEFixed: { @@ -488,6 +497,16 @@ describe('Vulnerabilties page', () => { await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20)); }); + it('sends filtered query if user types in the search bar', async () => { + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); + render(); + const cveSearchInput = screen.getByPlaceholderText(/search for/i); + jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } }); + await userEvent.type(cveSearchInput, '2022'); + expect((await screen.queryAllByText(/2023/i).length) === 0); + expect((await screen.findAllByText(/2022/i)).length === 6); + }); + it('renders no vulnerabilities if there are not any', async () => { jest.spyOn(api, 'get').mockResolvedValue({ status: 200, diff --git a/src/api.js b/src/api.js index 82228420..a6fb426c 100644 --- a/src/api.js +++ b/src/api.js @@ -79,10 +79,15 @@ const endpoints = { `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Title Documentation DownloadCount Source Description Licenses}}}}`, detailedImageInfo: (name, tag) => `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned Vulnerabilities {MaxSeverity Count} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`, - vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }) => - `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${ + vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => { + let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${ (pageNumber - 1) * pageSize - }}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`, + }}`; + if (!isEmpty(searchTerm)) { + query += `, searchedCVE: "${searchTerm}"`; + } + return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`; + }, imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }) => `/v2/_zot/ext/search?query={ImageListWithCVEFixed(id:"${cveId}", image:"${repoName}", requestedPage: {limit:${pageSize} offset:${ (pageNumber - 1) * pageSize diff --git a/src/components/Explore/Explore.jsx b/src/components/Explore/Explore.jsx index b6239a84..6a40e0e5 100644 --- a/src/components/Explore/Explore.jsx +++ b/src/components/Explore/Explore.jsx @@ -269,7 +269,7 @@ function Explore({ searchInputValue }) { if (!isLoading && !isEndOfList) { return
; } - return ''; + return; }; return ( diff --git a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx index cc7a60ae..eb4ba077 100644 --- a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx +++ b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx @@ -5,16 +5,17 @@ import { api, endpoints } from '../../../api'; // components import Collapse from '@mui/material/Collapse'; -import { Box, Card, CardContent, Divider, Stack, Typography } from '@mui/material'; +import { Box, Card, CardContent, Divider, Stack, Typography, InputBase } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; import { host } from '../../../host'; -import { isEmpty } from 'lodash'; +import { debounce, isEmpty } from 'lodash'; import { Link } from 'react-router-dom'; import Loading from '../../Shared/Loading'; import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck'; import { mapCVEInfo } from 'utilities/objectModels'; import { CVE_FIXEDIN_PAGE_SIZE, EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants'; +import SearchIcon from '@mui/icons-material/Search'; const useStyles = makeStyles(() => ({ card: { @@ -81,6 +82,25 @@ const useStyles = makeStyles(() => ({ fontWeight: '600', cursor: 'pointer', textAlign: 'center' + }, + search: { + position: 'relative', + minWidth: '100%', + flexDirection: 'row', + marginBottom: '1.7rem', + boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)', + border: '0.125rem solid #E7E7E7', + borderRadius: '1rem', + zIndex: 1155 + }, + searchIcon: { + color: '#52637A', + paddingRight: '3%' + }, + input: { + color: '#464141', + marginLeft: 1, + width: '90%' } })); @@ -236,27 +256,27 @@ function VulnerabilitiesDetails(props) { const { name, tag } = props; // pagination props + const [cveFilter, setCveFilter] = useState(''); const [pageNumber, setPageNumber] = useState(1); const [isEndOfList, setIsEndOfList] = useState(false); const listBottom = useRef(null); const getPaginatedCVEs = () => { - setIsLoading(true); api .get( - `${host()}${endpoints.vulnerabilitiesForRepo(`${name}:${tag}`, { pageNumber, pageSize: EXPLORE_PAGE_SIZE })}`, + `${host()}${endpoints.vulnerabilitiesForRepo( + `${name}:${tag}`, + { pageNumber, pageSize: EXPLORE_PAGE_SIZE }, + cveFilter + )}`, abortController.signal ) .then((response) => { if (response.data && response.data.data) { let cveInfo = response.data.data.CVEListForImage?.CVEList; let cveListData = mapCVEInfo(cveInfo); - const newCVEList = [...cveData, ...cveListData]; - setCveData(newCVEList); - setIsEndOfList( - response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE || - newCVEList.length >= response.data.data.CVEListForImage?.Page?.TotalCount - ); + setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData])); + setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE); } else if (response.data.errors) { setIsEndOfList(true); } @@ -270,11 +290,25 @@ function VulnerabilitiesDetails(props) { }); }; + const resetPagination = () => { + setIsLoading(true); + setIsEndOfList(false); + if (pageNumber !== 1) { + setPageNumber(1); + } else { + getPaginatedCVEs(); + } + }; + + const handleCveFilterChange = (e) => { + const { value } = e.target; + setCveFilter(value); + }; + + const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300)); + useEffect(() => { getPaginatedCVEs(); - return () => { - abortController.abort(); - }; }, [pageNumber]); // setup intersection obeserver for infinite scroll @@ -302,6 +336,18 @@ function VulnerabilitiesDetails(props) { }; }, [isLoading, isEndOfList]); + useEffect(() => { + if (isLoading) return; + resetPagination(); + }, [cveFilter]); + + useEffect(() => { + return () => { + abortController.abort(); + debouncedChangeHandler.cancel(); + }; + }, []); + const renderCVEs = () => { return !isEmpty(cveData) ? ( cveData.map((cve, index) => { @@ -347,6 +393,17 @@ function VulnerabilitiesDetails(props) { width: '100%' }} /> + + +
+ +
+
{renderCVEs()}