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..aa5efe85
--- /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 (
+
+ );
+}
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)