diff --git a/src/__tests__/Explore/Explore.test.js b/src/__tests__/Explore/Explore.test.js index f0f0baa6..92fbddfa 100644 --- a/src/__tests__/Explore/Explore.test.js +++ b/src/__tests__/Explore/Explore.test.js @@ -33,6 +33,7 @@ const mockImageList = { Name: 'alpine', Size: '2806985', LastUpdated: '2022-08-09T17:19:53.274069586Z', + IsBookmarked: false, NewestImage: { Tag: 'latest', Description: 'w', @@ -44,12 +45,19 @@ const mockImageList = { MaxSeverity: 'LOW', Count: 7 } - } + }, + Platforms: [ + { + Os: 'linux', + Arch: 'amd64' + } + ] }, { Name: 'mongo', Size: '231383863', LastUpdated: '2022-08-02T01:30:49.193203152Z', + IsBookmarked: false, NewestImage: { Tag: 'latest', Description: '', @@ -61,12 +69,19 @@ const mockImageList = { MaxSeverity: 'HIGH', Count: 2 } - } + }, + Platforms: [ + { + Os: 'linux', + Arch: 'amd64' + } + ] }, { Name: 'node', Size: '369311301', LastUpdated: '2022-08-23T00:20:40.144281895Z', + IsBookmarked: false, NewestImage: { Tag: 'latest', Description: '', @@ -78,12 +93,19 @@ const mockImageList = { MaxSeverity: 'CRITICAL', Count: 10 } - } + }, + Platforms: [ + { + Os: 'linux', + Arch: 'amd64' + } + ] }, { Name: 'centos', Size: '369311301', LastUpdated: '2022-08-23T00:20:40.144281895Z', + IsBookmarked: false, NewestImage: { Tag: 'latest', Description: '', @@ -95,12 +117,19 @@ const mockImageList = { MaxSeverity: 'NONE', Count: 10 } - } + }, + Platforms: [ + { + Os: 'linux', + Arch: 'amd64' + } + ] }, { Name: 'debian', Size: '369311301', LastUpdated: '2022-08-23T00:20:40.144281895Z', + IsBookmarked: false, NewestImage: { Tag: 'latest', Description: '', @@ -112,12 +141,23 @@ const mockImageList = { MaxSeverity: 'MEDIUM', Count: 10 } - } + }, + Platforms: [ + { + Os: 'linux', + Arch: 'amd64' + }, + { + Os: 'windows', + Arch: 'amd64' + } + ] }, { Name: 'mysql', Size: '369311301', LastUpdated: '2022-08-23T00:20:40.144281895Z', + IsBookmarked: false, NewestImage: { Tag: 'latest', Description: '', @@ -129,12 +169,19 @@ const mockImageList = { MaxSeverity: 'UNKNOWN', Count: 10 } - } + }, + Platforms: [ + { + Os: 'linux', + Arch: 'amd64' + } + ] }, { Name: 'base', Size: '369311301', LastUpdated: '2022-08-23T00:20:40.144281895Z', + IsBookmarked: false, NewestImage: { Tag: 'latest', Description: '', @@ -146,12 +193,40 @@ const mockImageList = { MaxSeverity: '', Count: 10 } - } + }, + Platforms: [ + { + Os: 'linux', + Arch: 'amd64' + } + ] } ] } }; +const filteredMockImageListWindows = () => { + const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) => + r.Platforms.map((pf) => pf.Os).includes('windows') + ); + return { + GlobalSearch: { + Page: { TotalCount: 1, ItemCount: 1 }, + Repos: filteredRepos + } + }; +}; + +const filteredMockImageListSigned = () => { + const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) => r.NewestImage.IsSigned); + return { + GlobalSearch: { + Page: { TotalCount: 6, ItemCount: 6 }, + Repos: filteredRepos + } + }; +}; + beforeEach(() => { // IntersectionObserver isn't available in test environment const mockIntersectionObserver = jest.fn(); @@ -235,4 +310,28 @@ describe('Explore component', () => { const filterCheckboxes = await screen.findAllByRole('checkbox'); expect(filterCheckboxes[0]).toBeChecked(); }); + + it('should filter the images based on filter cards', async () => { + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); + render(); + expect(await screen.findAllByTestId('repo-card')).toHaveLength(mockImageList.GlobalSearch.Repos.length); + const windowsCheckbox = (await screen.findAllByRole('checkbox'))[0]; + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: filteredMockImageListWindows() } }); + await userEvent.click(windowsCheckbox); + expect(windowsCheckbox).toBeChecked(); + expect(await screen.findAllByTestId('repo-card')).toHaveLength(1); + const signedCheckboxLabel = await screen.findByText(/signed images/i); + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: filteredMockImageListSigned() } }); + await userEvent.click(signedCheckboxLabel); + expect(await screen.findAllByTestId('repo-card')).toHaveLength(6); + }); + + it('should bookmark a repo if bookmark button is clicked', async () => { + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); + render(); + const bookmarkButton = (await screen.findAllByTestId('bookmark-button'))[0]; + jest.spyOn(api, 'put').mockResolvedValueOnce({ status: 200, data: {} }); + await userEvent.click(bookmarkButton); + expect(await screen.findAllByTestId('bookmarked')).toHaveLength(1); + }); }); diff --git a/src/__tests__/HomePage/Home.test.js b/src/__tests__/HomePage/Home.test.js index 76ad42b1..db439563 100644 --- a/src/__tests__/HomePage/Home.test.js +++ b/src/__tests__/HomePage/Home.test.js @@ -122,6 +122,48 @@ const mockImageListRecent = { } }; +const mockImageListBookmarks = { + GlobalSearch: { + Page: { TotalCount: 3, ItemCount: 2 }, + Repos: [ + { + 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 + } + } + } + ] + } +}; + beforeEach(() => { window.scrollTo = jest.fn(); }); @@ -134,27 +176,27 @@ afterEach(() => { describe('Home component', () => { it('fetches image data and renders popular, bookmarks and recently updated', async () => { jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); - jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } }); + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } }); render(); - await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(2)); - await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(2)); + await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(3)); + await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(3)); await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1)); }); it('renders signature icons', async () => { jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); - jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } }); + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } }); render(); - expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(2); - expect(await screen.findAllByTestId('verified-icon')).toHaveLength(3); + expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(3); + expect(await screen.findAllByTestId('verified-icon')).toHaveLength(4); }); it('renders vulnerability icons', async () => { jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); - jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } }); + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } }); render(); - expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(2); - expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(2); + expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(3); + expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(3); expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1); }); @@ -162,15 +204,17 @@ describe('Home component', () => { jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} }); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); render(); - await waitFor(() => expect(error).toBeCalledTimes(2)); + await waitFor(() => expect(error).toBeCalledTimes(3)); }); it('should redirect to explore page when clicking view all popular', async () => { jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } }); + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListBookmarks } }); render(); const viewAllButtons = await screen.findAllByText(/view all/i); - expect(viewAllButtons).toHaveLength(2); + expect(viewAllButtons).toHaveLength(3); + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: [] } }); fireEvent.click(viewAllButtons[0]); expect(mockedUsedNavigate).toHaveBeenCalledWith({ pathname: `/explore`, @@ -181,5 +225,10 @@ describe('Home component', () => { pathname: `/explore`, search: createSearchParams({ sortby: sortByCriteria.updateTime.value }).toString() }); + fireEvent.click(viewAllButtons[2]); + expect(mockedUsedNavigate).toHaveBeenCalledWith({ + pathname: `/explore`, + search: createSearchParams({ filter: 'IsBookmarked' }).toString() + }); }); }); diff --git a/src/__tests__/RepoPage/Repo.test.js b/src/__tests__/RepoPage/Repo.test.js index 82094e3d..29639c1f 100644 --- a/src/__tests__/RepoPage/Repo.test.js +++ b/src/__tests__/RepoPage/Repo.test.js @@ -4,6 +4,7 @@ import React from 'react'; import { api } from 'api'; import { createSearchParams } from 'react-router-dom'; import MockThemeProvier from '__mocks__/MockThemeProvider'; +import userEvent from '@testing-library/user-event'; const RepoDetailsThemeWrapper = () => { return ( @@ -45,6 +46,7 @@ const mockRepoDetailsData = { LastUpdated: '2023-01-30T15:05:35.420124619Z', Size: '451554070', Vendors: ['[The Node.js Docker Team](https://github.com/nodejs/docker-node)\n'], + IsBookmarked: false, NewestImage: { RepoName: 'mongo', IsSigned: true, @@ -298,4 +300,13 @@ describe('Repo details component', () => { search: createSearchParams({ filter: 'linux' }).toString() }); }); + + it('should bookmark a repo if bookmark button is clicked', async () => { + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockRepoDetailsData } }); + render(); + const bookmarkButton = await screen.findByTestId('bookmark-button'); + jest.spyOn(api, 'put').mockResolvedValue({ status: 200, data: {} }); + await userEvent.click(bookmarkButton); + expect(await screen.findByTestId('bookmarked')).toBeInTheDocument(); + }); }); diff --git a/src/__tests__/TagPage/TagDetails.test.js b/src/__tests__/TagPage/TagDetails.test.js index f4cf327f..ef936880 100644 --- a/src/__tests__/TagPage/TagDetails.test.js +++ b/src/__tests__/TagPage/TagDetails.test.js @@ -368,7 +368,51 @@ const mockDependenciesList = { } }; -const mockDependentsList = mockDependenciesList; +const mockDependentsList = { + data: { + DerivedImageList: { + Page: { ItemCount: 4, TotalCount: 4 }, + Results: [ + { + RepoName: 'project-stacker/c3/static-ubuntu-amd64', + Tag: 'tag1', + Manifests: [], + Vulnerabilities: { + MaxSeverity: 'HIGH', + Count: 5 + } + }, + { + RepoName: 'tag2', + Tag: 'tag2', + Manifests: [], + Vulnerabilities: { + MaxSeverity: 'CRITICAL', + Count: 2 + } + }, + { + RepoName: 'tag3', + Tag: 'tag3', + Manifests: [], + Vulnerabilities: { + MaxSeverity: 'LOW', + Count: 7 + } + }, + { + RepoName: 'tag4', + Tag: 'tag4', + Manifests: [], + Vulnerabilities: { + MaxSeverity: 'HIGH', + Count: 5 + } + } + ] + } + } +}; const mockCVEList = { CVEListForImage: { diff --git a/src/api.js b/src/api.js index 6cbf73e2..11f7e094 100644 --- a/src/api.js +++ b/src/api.js @@ -24,7 +24,7 @@ const api = { 'Content-Type': 'application/json' }; const token = localStorage.getItem('token'); - if (token) { + if (token && token !== '-') { const authHeaders = { Accept: 'application/json', 'Content-Type': 'application/json', @@ -78,9 +78,9 @@ const endpoints = { repoList: ({ pageNumber = 1, pageSize = 15 } = {}) => `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${ (pageNumber - 1) * pageSize - }}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned Documentation Vendor Labels} DownloadCount}}}`, + }}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned Documentation Vendor Labels} IsStarred IsBookmarked DownloadCount}}}`, detailedRepoInfo: (name) => - `/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 Vendor Title Documentation DownloadCount Source Description Licenses}}}}`, + `/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 IsStarred IsBookmarked NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`, detailedImageInfo: (name, tag) => `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`, vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => { @@ -119,9 +119,10 @@ const endpoints = { if (filter.Os) filterParam += ` Os:${!isEmpty(filter.Os) ? `${JSON.stringify(filter.Os)}` : '""'}`; if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`; if (filter.HasToBeSigned) filterParam += ` HasToBeSigned: ${filter.HasToBeSigned}`; + if (filter.IsBookmarked) filterParam += ` IsBookmarked: ${filter.IsBookmarked}`; filterParam += '}'; if (Object.keys(filter).length === 0) filterParam = ''; - 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 Licenses Vendor Labels } DownloadCount}}}`; + return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Page {TotalCount ItemCount} Repos {Name LastUpdated Size Platforms { Os Arch } IsStarred IsBookmarked NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned Licenses Vendor Labels } DownloadCount}}}`; }, imageSuggestions: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => { const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`; @@ -129,7 +130,8 @@ const endpoints = { return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag}}}`; }, referrers: ({ repo, digest, type = '' }) => - `/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}` + `/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`, + bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark` }; export { api, endpoints }; diff --git a/src/assets/Google.png b/src/assets/Google.png deleted file mode 100644 index f9eb8bf3..00000000 Binary files a/src/assets/Google.png and /dev/null differ diff --git a/src/assets/Zot-white-text.png b/src/assets/Zot-white-text.png deleted file mode 100644 index 87ded54c..00000000 Binary files a/src/assets/Zot-white-text.png and /dev/null differ diff --git a/src/assets/Zot1.png b/src/assets/Zot1.png deleted file mode 100644 index bdf20286..00000000 Binary files a/src/assets/Zot1.png and /dev/null differ diff --git a/src/assets/Zot1.svg b/src/assets/Zot1.svg deleted file mode 100644 index b9ec09a2..00000000 --- a/src/assets/Zot1.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/Zot2.png b/src/assets/Zot2.png deleted file mode 100644 index a9a99a4b..00000000 Binary files a/src/assets/Zot2.png and /dev/null differ diff --git a/src/assets/background.png b/src/assets/background.png deleted file mode 100644 index 7e666025..00000000 Binary files a/src/assets/background.png and /dev/null differ diff --git a/src/assets/zot-white.png b/src/assets/zot-white.png deleted file mode 100644 index d5761c7f..00000000 Binary files a/src/assets/zot-white.png and /dev/null differ diff --git a/src/assets/zotLogo.svg b/src/assets/zotLogo.svg deleted file mode 100644 index 9686660e..00000000 --- a/src/assets/zotLogo.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/assets/zotLogoSmall.png b/src/assets/zotLogoSmall.png deleted file mode 100644 index 92ab98b2..00000000 Binary files a/src/assets/zotLogoSmall.png and /dev/null differ diff --git a/src/components/Explore/Explore.jsx b/src/components/Explore/Explore.jsx index 3b42edc8..d8adb7ab 100644 --- a/src/components/Explore/Explore.jsx +++ b/src/components/Explore/Explore.jsx @@ -70,7 +70,7 @@ function Explore({ searchInputValue }) { const [queryParams] = useSearchParams(); const search = queryParams.get('search'); // filtercard filters - const [imageFilters, setImageFilters] = useState(false); + const [imageFilters, setImageFilters] = useState({}); const [osFilters, setOSFilters] = useState([]); const [archFilters, setArchFilters] = useState([]); // pagination props @@ -88,8 +88,8 @@ function Explore({ searchInputValue }) { let filter = {}; filter = !isEmpty(osFilters) ? { ...filter, Os: osFilters } : filter; filter = !isEmpty(archFilters) ? { ...filter, Arch: archFilters } : filter; - if (imageFilters) { - filter = { ...filter, HasToBeSigned: imageFilters }; + if (!isEmpty(Object.keys(imageFilters))) { + filter = { ...filter, ...imageFilters }; } return filter; }; @@ -101,6 +101,8 @@ function Explore({ searchInputValue }) { setOSFilters([...osFilters, preselectedFilter]); } else if (filterConstants.archFilters.map((f) => f.value).includes(preselectedFilter)) { setArchFilters([...archFilters, preselectedFilter]); + } else if (filterConstants.imageFilters.map((f) => f.value).includes(preselectedFilter)) { + setImageFilters({ ...imageFilters, [preselectedFilter]: true }); } queryParams.delete('filter'); } @@ -219,6 +221,7 @@ function Explore({ searchInputValue }) { description={item.description} downloads={item.downloads} isSigned={item.isSigned} + isBookmarked={item.isBookmarked} vendor={item.vendor} platforms={item.platforms} key={index} diff --git a/src/components/Home/Home.jsx b/src/components/Home/Home.jsx index 999c184c..44c38cf7 100644 --- a/src/components/Home/Home.jsx +++ b/src/components/Home/Home.jsx @@ -8,7 +8,8 @@ import { mapToRepo } from 'utilities/objectModels'; import Loading from '../Shared/Loading'; import { useNavigate, createSearchParams } from 'react-router-dom'; import { sortByCriteria } from 'utilities/sortCriteria'; -import { HOME_POPULAR_PAGE_SIZE, HOME_RECENT_PAGE_SIZE } from 'utilities/paginationConstants'; +import { HOME_POPULAR_PAGE_SIZE, HOME_RECENT_PAGE_SIZE, HOME_BOOKMARKS_PAGE_SIZE } from 'utilities/paginationConstants'; +import { isEmpty } from 'lodash'; const useStyles = makeStyles((theme) => ({ gridWrapper: { @@ -82,13 +83,18 @@ const useStyles = makeStyles((theme) => ({ function Home() { const [isLoading, setIsLoading] = useState(true); const [popularData, setPopularData] = useState([]); + const [isLoadingPopular, setIsLoadingPopular] = useState(true); const [recentData, setRecentData] = useState([]); + const [isLoadingRecent, setIsLoadingRecent] = useState(true); + const [bookmarkData, setBookmarkData] = useState([]); + const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(true); + const navigate = useNavigate(); const abortController = useMemo(() => new AbortController(), []); const classes = useStyles(); const getPopularData = () => { - setIsLoading(true); + setIsLoadingPopular(true); api .get( `${host()}${endpoints.globalSearch({ @@ -107,15 +113,18 @@ function Home() { }); setPopularData(repoData); setIsLoading(false); + setIsLoadingPopular(false); } }) .catch((e) => { console.error(e); + setIsLoading(false); + setIsLoadingPopular(false); }); }; const getRecentData = () => { - setIsLoading(true); + setIsLoadingRecent(true); api .get( `${host()}${endpoints.globalSearch({ @@ -134,56 +143,66 @@ function Home() { }); setRecentData(repoData); setIsLoading(false); + setIsLoadingRecent(false); + } + }) + .catch((e) => { + setIsLoading(false); + setIsLoadingRecent(false); + console.error(e); + }); + }; + + const getBookmarks = () => { + setIsLoadingBookmarks(true); + api + .get( + `${host()}${endpoints.globalSearch({ + searchQuery: '', + pageNumber: 1, + pageSize: HOME_BOOKMARKS_PAGE_SIZE, + sortBy: sortByCriteria.relevance?.value, + filter: { IsBookmarked: true } + })}`, + abortController.signal + ) + .then((response) => { + if (response.data && response.data.data) { + let repoList = response.data.data.GlobalSearch.Repos; + let repoData = repoList.map((responseRepo) => { + return mapToRepo(responseRepo); + }); + setBookmarkData(repoData); + setIsLoading(false); + setIsLoadingBookmarks(false); } }) .catch((e) => { + setIsLoading(false); + setIsLoadingBookmarks(false); console.error(e); }); }; useEffect(() => { window.scrollTo(0, 0); + setIsLoading(true); getPopularData(); getRecentData(); + getBookmarks(); return () => { abortController.abort(); }; }, []); - const handleClickViewAll = (target) => { - navigate({ pathname: `/explore`, search: createSearchParams({ sortby: target }).toString() }); - }; - - const renderMostPopular = () => { - return ( - popularData && - popularData.map((item, index) => { - return ( - - ); - }) - ); + const handleClickViewAll = (type, value) => { + navigate({ pathname: `/explore`, search: createSearchParams({ [type]: value }).toString() }); }; - const renderRecentlyUpdated = () => { + const renderCards = (cardArray) => { return ( - recentData && - recentData.map((item, index) => { + cardArray && + cardArray.map((item, index) => { return ( -
handleClickViewAll(sortByCriteria.downloads.value)}> +
handleClickViewAll('sortby', sortByCriteria.downloads.value)}> View all
- {renderMostPopular()} + {isLoadingPopular ? : renderCards(popularData)} {/* currently most popular will be by downloads until stars are implemented */}
@@ -236,13 +256,34 @@ function Home() { handleClickViewAll(sortByCriteria.updateTime.value)} + onClick={() => handleClickViewAll('sortby', sortByCriteria.updateTime.value)} > View all
- {renderRecentlyUpdated()} + {isLoadingRecent ? : renderCards(recentData)} + {!isEmpty(bookmarkData) && ( + <> + +
+ + Bookmarks + +
+
+ handleClickViewAll('filter', 'IsBookmarked')} + > + View all + +
+
+ {isLoadingBookmarks ? : renderCards(bookmarkData)} + + )} )} diff --git a/src/components/Repo/RepoDetails.jsx b/src/components/Repo/RepoDetails.jsx index d15e307a..95bc8289 100644 --- a/src/components/Repo/RepoDetails.jsx +++ b/src/components/Repo/RepoDetails.jsx @@ -3,16 +3,18 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; // external import { DateTime } from 'luxon'; +import { isEmpty, uniq } from 'lodash'; // utility import { api, endpoints } from '../../api'; +import { host } from '../../host'; import { useParams, useNavigate, createSearchParams } from 'react-router-dom'; // components -import Tags from './Tabs/Tags.jsx'; -import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography } from '@mui/material'; +import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography, IconButton } from '@mui/material'; +import BookmarkIcon from '@mui/icons-material/Bookmark'; +import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; import makeStyles from '@mui/styles/makeStyles'; -import { host } from '../../host'; // placeholder images import repocube1 from '../../assets/repocube-1.png'; @@ -20,12 +22,13 @@ import repocube2 from '../../assets/repocube-2.png'; import repocube3 from '../../assets/repocube-3.png'; import repocube4 from '../../assets/repocube-4.png'; +import Tags from './Tabs/Tags.jsx'; import RepoDetailsMetadata from './RepoDetailsMetadata'; import Loading from '../Shared/Loading'; import { Markdown } from 'utilities/MarkdowntojsxWrapper'; -import { isEmpty, uniq } from 'lodash'; import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; import { mapToRepoFromRepoInfo } from 'utilities/objectModels'; +import { isAuthenticated } from 'utilities/authUtilities'; const useStyles = makeStyles((theme) => ({ pageWrapper: { @@ -216,6 +219,17 @@ function RepoDetails() { )); }; + const handleBookmarkClick = () => { + api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => { + if (response.status === 200) { + setRepoDetailData((prevState) => ({ + ...prevState, + isBookmarked: !prevState.isBookmarked + })); + } + }); + }; + const getVendor = () => { return `${repoDetailData.newestTag?.Vendor || 'Vendor not available'} •`; }; @@ -256,12 +270,18 @@ function RepoDetails() { - + + {isAuthenticated() && ( + + {repoDetailData?.isBookmarked ? ( + + ) : ( + + )} + + )} {repoDetailData?.title || 'Title not available'} diff --git a/src/components/Shared/FilterCard.jsx b/src/components/Shared/FilterCard.jsx index af7712d6..ffa60140 100644 --- a/src/components/Shared/FilterCard.jsx +++ b/src/components/Shared/FilterCard.jsx @@ -1,6 +1,6 @@ import { Card, CardContent, Checkbox, FormControlLabel, Stack, Tooltip, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import { isBoolean, isArray } from 'lodash'; +import { isArray, isNil } from 'lodash'; import React from 'react'; const useStyles = makeStyles((theme) => ({ @@ -42,17 +42,17 @@ function FilterCard(props) { const classes = useStyles(); const { title, filters, updateFilters, filterValue, wrapperLoading } = props; - const handleFilterClicked = (event, changedFilterLabel, changedFilterValue) => { + const handleFilterClicked = (event, changedFilterValue) => { const { checked } = event.target; if (checked) { - if (filters[0]?.type === 'boolean') { - updateFilters(checked); + if (!isArray(filterValue)) { + updateFilters({ ...filterValue, [changedFilterValue]: true }); } else { updateFilters([...filterValue, changedFilterValue]); } } else { - if (filters[0]?.type === 'boolean') { - updateFilters(checked); + if (!isArray(filterValue)) { + updateFilters({ ...filterValue, [changedFilterValue]: false }); } else { updateFilters(filterValue.filter((e) => e !== changedFilterValue)); } @@ -60,12 +60,14 @@ function FilterCard(props) { } }; - const getCheckboxStatus = (label) => { + const getCheckboxStatus = (filter) => { + if (isNil(filter)) { + return false; + } if (isArray(filterValue)) { - return filterValue?.includes(label); - } else if (isBoolean(filterValue)) { - return filterValue; + return filterValue?.includes(filter.label); } + return filterValue[filter.value] || false; }; const getFilterRows = () => { @@ -79,8 +81,8 @@ function FilterCard(props) { control={} label={filter.label} id={title} - checked={getCheckboxStatus(filter.label)} - onChange={() => handleFilterClicked(event, filter.label, filter.value)} + checked={getCheckboxStatus(filter)} + onChange={() => handleFilterClicked(event, filter.value)} disabled={wrapperLoading} /> diff --git a/src/components/Shared/RepoCard.jsx b/src/components/Shared/RepoCard.jsx index 8b4ef58b..5060e323 100644 --- a/src/components/Shared/RepoCard.jsx +++ b/src/components/Shared/RepoCard.jsx @@ -1,9 +1,16 @@ // react global -import React, { useRef } from 'react'; +import React, { useRef, useMemo, useState } from 'react'; import { useNavigate, createSearchParams } from 'react-router-dom'; // utility import { DateTime } from 'luxon'; +import { uniq } from 'lodash'; + +// api module +import { api, endpoints } from '../../api'; +import { host } from '../../host'; +import { isAuthenticated } from '../../utilities/authUtilities'; + // components import { Card, @@ -15,9 +22,13 @@ import { Chip, Grid, Tooltip, + IconButton, useMediaQuery } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; +import BookmarkIcon from '@mui/icons-material/Bookmark'; +import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; +import { useTheme } from '@emotion/react'; // placeholder images import repocube1 from '../../assets/repocube-1.png'; @@ -27,8 +38,6 @@ import repocube4 from '../../assets/repocube-4.png'; import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; import { Markdown } from 'utilities/MarkdowntojsxWrapper'; -import { uniq } from 'lodash'; -import { useTheme } from '@emotion/react'; // temporary utility to get image const randomIntFromInterval = (min, max) => { @@ -89,7 +98,8 @@ const useStyles = makeStyles((theme) => ({ } }, contentRight: { - height: '100%' + justifyContent: 'flex-end', + textAlign: 'end' }, contentRightLabel: { fontSize: '0.75rem', @@ -105,6 +115,10 @@ const useStyles = makeStyles((theme) => ({ textAlign: 'end', marginLeft: '0.5rem' }, + contentRightActions: { + alignItems: 'flex-end', + justifyContent: 'flex-end' + }, signedBadge: { color: '#9ccc65', height: '1.375rem', @@ -161,7 +175,23 @@ function RepoCard(props) { const isXsSize = useMediaQuery(theme.breakpoints.down('md')); const MAX_PLATFORM_CHIPS = isXsSize ? 3 : 6; - const { name, vendor, platforms, description, downloads, isSigned, lastUpdated, version, vulnerabilityData } = props; + const abortController = useMemo(() => new AbortController(), []); + + const { + name, + vendor, + platforms, + description, + downloads, + isSigned, + lastUpdated, + version, + vulnerabilityData, + isBookmarked + } = props; + + // keep a local bookmark state to display in the ui dynamically on updates + const [currentBookmarkValue, setCurrentBookmarkValue] = useState(isBookmarked); const goToDetails = () => { navigate(`/image/${encodeURIComponent(name)}`); @@ -174,6 +204,16 @@ function RepoCard(props) { navigate({ pathname: `/explore`, search: createSearchParams({ filter: textContent }).toString() }); }; + const handleBookmarkClick = (event) => { + event.stopPropagation(); + event.preventDefault(); + api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => { + if (response.status === 200) { + setCurrentBookmarkValue((prevState) => !prevState); + } + }); + }; + const platformChips = () => { const filteredPlatforms = uniq(platforms?.flatMap((platform) => [platform.Os, platform.Arch])); const hiddenChips = filteredPlatforms.length - MAX_PLATFORM_CHIPS; @@ -205,8 +245,22 @@ function RepoCard(props) { return lastDate; }; + const renderBookmark = () => { + return ( + isAuthenticated() && ( + + {currentBookmarkValue ? ( + + ) : ( + + )} + + ) + ); + }; + return ( - + - - - - - Downloads • - - - {!isNaN(downloads) ? downloads : `not available`} - - - {/* + + + + Downloads • + + + {!isNaN(downloads) ? downloads : `not available`} + + + {/* Rating • @@ -283,6 +336,8 @@ function RepoCard(props) { #1 */} + + {renderBookmark()} diff --git a/src/index.css b/src/index.css index 68c85d28..fa5ef1f6 100644 --- a/src/index.css +++ b/src/index.css @@ -60,7 +60,6 @@ body { min-height: 100vh; overflow-x: hidden; - /* background-image: url(./assets/background.png); */ background-color: #f6f7f9 !important; } diff --git a/src/utilities/authUtilities.js b/src/utilities/authUtilities.js new file mode 100644 index 00000000..55678cf5 --- /dev/null +++ b/src/utilities/authUtilities.js @@ -0,0 +1,5 @@ +const isAuthenticated = () => { + return localStorage.getItem('token') !== '-'; +}; + +export { isAuthenticated }; diff --git a/src/utilities/filterConstants.js b/src/utilities/filterConstants.js index 01054f5f..5ae78784 100644 --- a/src/utilities/filterConstants.js +++ b/src/utilities/filterConstants.js @@ -14,6 +14,11 @@ const imageFilters = [ label: 'Signed Images', value: 'HasToBeSigned', type: 'boolean' + }, + { + label: 'Bookmarks', + value: 'IsBookmarked', + type: 'boolean' } ]; diff --git a/src/utilities/objectModels.js b/src/utilities/objectModels.js index cefaffd9..b548bd3e 100644 --- a/src/utilities/objectModels.js +++ b/src/utilities/objectModels.js @@ -5,6 +5,8 @@ const mapToRepo = (responseRepo) => { tags: responseRepo.NewestImage?.Labels, description: responseRepo.NewestImage?.Description, isSigned: responseRepo.NewestImage?.IsSigned, + isBookmarked: responseRepo.IsBookmarked, + isStarred: responseRepo.IsStarred, platforms: responseRepo.Platforms, licenses: responseRepo.NewestImage?.Licenses, size: responseRepo.Size, @@ -32,9 +34,11 @@ const mapToRepoFromRepoInfo = (responseRepoInfo) => { downloads: responseRepoInfo.Summary?.NewestImage?.DownloadCount, overview: responseRepoInfo.Summary?.NewestImage?.Documentation, license: responseRepoInfo.Summary?.NewestImage?.Licenses, - vulnerabiltySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity, + vulnerabilitySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity, vulnerabilityCount: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.Count, isSigned: responseRepoInfo.Summary?.NewestImage?.IsSigned, + isBookmarked: responseRepoInfo.Summary?.IsBookmarked, + isStarred: responseRepoInfo.Summary?.IsStarred, logo: responseRepoInfo.Summary?.NewestImage?.Logo }; }; diff --git a/src/utilities/paginationConstants.js b/src/utilities/paginationConstants.js index 757f60f2..9d407daa 100644 --- a/src/utilities/paginationConstants.js +++ b/src/utilities/paginationConstants.js @@ -3,6 +3,7 @@ const EXPLORE_PAGE_SIZE = 10; const HOME_PAGE_SIZE = 10; const HOME_POPULAR_PAGE_SIZE = 3; const HOME_RECENT_PAGE_SIZE = 2; +const HOME_BOOKMARKS_PAGE_SIZE = 2; const CVE_FIXEDIN_PAGE_SIZE = 5; export { @@ -11,5 +12,6 @@ export { HOME_PAGE_SIZE, CVE_FIXEDIN_PAGE_SIZE, HOME_POPULAR_PAGE_SIZE, - HOME_RECENT_PAGE_SIZE + HOME_RECENT_PAGE_SIZE, + HOME_BOOKMARKS_PAGE_SIZE };