diff --git a/src/__tests__/RepoPage/Tags.test.js b/src/__tests__/RepoPage/Tags.test.js index 0cee2c12..9ae10105 100644 --- a/src/__tests__/RepoPage/Tags.test.js +++ b/src/__tests__/RepoPage/Tags.test.js @@ -22,6 +22,7 @@ const mockedTagsData = [ { tag: 'latest', vendor: 'test1', + isDeletable: true, manifests: [ { lastUpdated: '2022-07-19T18:06:18.818788283Z', @@ -37,6 +38,7 @@ const mockedTagsData = [ { tag: 'bullseye', vendor: 'test1', + isDeletable: true, manifests: [ { digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', @@ -52,6 +54,7 @@ const mockedTagsData = [ { tag: '1.5.2', vendor: 'test1', + isDeletable: true, manifests: [ { lastUpdated: '2022-07-19T18:06:18.818788283Z', @@ -76,6 +79,18 @@ describe('Tags component', () => { await waitFor(() => expect(screen.queryByText(/OS\/ARCH/i)).not.toBeInTheDocument()); }); + it('should see delete tag button and its dialog', async () => { + render(); + const deleteBtn = await screen.findAllByTestId('DeleteIcon'); + fireEvent.click(deleteBtn[0]); + expect(await screen.findByTestId('delete-dialog')).toBeInTheDocument(); + const confirmBtn = await screen.findByTestId('confirm-delete'); + expect(confirmBtn).toBeInTheDocument(); + fireEvent.click(confirmBtn); + expect(await screen.findByTestId('confirm-delete')).toBeInTheDocument(); + expect(await screen.findByTestId('cancel-delete')).toBeInTheDocument(); + }); + it('should navigate to tag page details when tag is clicked', async () => { render(); const tagLink = await screen.findByText('latest'); diff --git a/src/api.js b/src/api.js index 15b7f794..8d367389 100644 --- a/src/api.js +++ b/src/api.js @@ -81,12 +81,13 @@ const endpoints = { authConfig: `/v2/_zot/ext/mgmt`, openidAuth: `/zot/auth/login`, logout: `/zot/auth/logout`, + deleteImage: (name, tag) => `/v2/${name}/manifests/${tag}`, 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 SignatureInfo { Tool IsTrusted Author } Documentation Vendor Labels} IsStarred IsBookmarked StarCount 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 IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } 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 IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } 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 SignatureInfo { Tool IsTrusted Author } 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 = '') => { diff --git a/src/components/Repo/RepoDetails.jsx b/src/components/Repo/RepoDetails.jsx index b3df2e9b..5ee2b158 100644 --- a/src/components/Repo/RepoDetails.jsx +++ b/src/components/Repo/RepoDetails.jsx @@ -197,6 +197,10 @@ function RepoDetails() { }; }, [name]); + const handleDeleteTag = (removed) => { + setTags((prevState) => prevState.filter((tag) => tag.tag !== removed)); + }; + const handlePlatformChipClick = (event) => { const { textContent } = event.target; event.stopPropagation(); @@ -223,7 +227,7 @@ function RepoDetails() { const handleBookmarkClick = () => { api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => { - if (response.status === 200) { + if (response && response.status === 200) { setRepoDetailData((prevState) => ({ ...prevState, isBookmarked: !prevState.isBookmarked @@ -341,7 +345,7 @@ function RepoDetails() { - + diff --git a/src/components/Repo/Tabs/Tags.jsx b/src/components/Repo/Tabs/Tags.jsx index 808d55f5..6abb6f38 100644 --- a/src/components/Repo/Tabs/Tags.jsx +++ b/src/components/Repo/Tabs/Tags.jsx @@ -43,7 +43,7 @@ const useStyles = makeStyles(() => ({ export default function Tags(props) { const classes = useStyles(); - const { tags } = props; + const { tags, repoName, onTagDelete } = props; const [tagsFilter, setTagsFilter] = useState(''); const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value); @@ -63,6 +63,9 @@ export default function Tags(props) { lastUpdated={tag.lastUpdated} vendor={tag.vendor} manifests={tag.manifests} + repo={repoName} + onTagDelete={onTagDelete} + isDeletable={tag.isDeletable} /> ); }) diff --git a/src/components/Shared/DeleteTag.jsx b/src/components/Shared/DeleteTag.jsx new file mode 100644 index 00000000..e2772efd --- /dev/null +++ b/src/components/Shared/DeleteTag.jsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { IconButton } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; + +// utility +import { api, endpoints } from '../../api'; + +// components +import DeleteTagConfirmDialog from 'components/Shared/DeleteTagConfirmDialog'; +import { host } from '../../host'; + +export default function DeleteTag(props) { + const { repo, tag, onTagDelete } = props; + const [open, setOpen] = useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const deleteTag = (repo, tag) => { + api + .delete(`${host()}${endpoints.deleteImage(repo, tag)}`) + .then((response) => { + if (response && response.status == 202) { + onTagDelete(tag); + } + }) + .catch((err) => { + console.error(err); + }); + }; + + const onConfirm = () => { + deleteTag(repo, tag); + }; + + return ( + + + + + + + ); +} diff --git a/src/components/Shared/DeleteTagConfirmDialog.jsx b/src/components/Shared/DeleteTagConfirmDialog.jsx new file mode 100644 index 00000000..4964c9a0 --- /dev/null +++ b/src/components/Shared/DeleteTagConfirmDialog.jsx @@ -0,0 +1,30 @@ +import React from 'react'; + +// components +import { Button, Dialog, DialogTitle, DialogActions } from '@mui/material'; + +export default function DeleteTagConfirmDialog(props) { + const { onClose, open, title, onConfirm } = props; + + return ( + + {title} + + + + + + ); +} diff --git a/src/components/Shared/TagCard.jsx b/src/components/Shared/TagCard.jsx index f2eb7624..91fb0be9 100644 --- a/src/components/Shared/TagCard.jsx +++ b/src/components/Shared/TagCard.jsx @@ -6,6 +6,7 @@ import { Markdown } from 'utilities/MarkdowntojsxWrapper'; import transform from 'utilities/transform'; import { DateTime } from 'luxon'; import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; +import DeleteTag from 'components/Shared/DeleteTag'; const useStyles = makeStyles((theme) => ({ card: { @@ -78,9 +79,9 @@ const useStyles = makeStyles((theme) => ({ })); export default function TagCard(props) { - const { repoName, tag, lastUpdated, vendor, manifests } = props; - + const { repoName, tag, lastUpdated, vendor, manifests, repo, onTagDelete, isDeletable } = props; const [open, setOpen] = useState(false); + const classes = useStyles(); const lastDate = lastUpdated @@ -99,9 +100,12 @@ export default function TagCard(props) { return ( - - Tag - + + + Tag + + {isDeletable && } + goToTags()}> {repoName && `${repoName}:`} {tag} diff --git a/src/utilities/objectModels.js b/src/utilities/objectModels.js index 34bb880c..dc97568f 100644 --- a/src/utilities/objectModels.js +++ b/src/utilities/objectModels.js @@ -69,6 +69,7 @@ const mapToImage = (responseImage) => { authors: responseImage.Authors, vulnerabiltySeverity: responseImage.Vulnerabilities?.MaxSeverity, vulnerabilityCount: responseImage.Vulnerabilities?.Count, + isDeletable: responseImage.IsDeletable, // frontend only prop to increase interop with Repo objects and code reusability name: `${responseImage.RepoName}:${responseImage.Tag}` }; diff --git a/tests/values/test-constants.js b/tests/values/test-constants.js index 819cdaec..4d7399b6 100644 --- a/tests/values/test-constants.js +++ b/tests/values/test-constants.js @@ -19,7 +19,7 @@ const pageSizes = { const endpoints = { repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20StarCount%20DownloadCount}}}`, detailedRepoInfo: (name) => - `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`, + `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20IsDeletable%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`, globalSearch: (searchTerm, sortCriteria, pageNumber = 1, pageSize = 10) => `/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${ 10 * (pageNumber - 1)