Commit 0e9004a0 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '42399-registry-confirm-deletion' into 'master'

Add confirmation for registry image deletion

Closes #42399

See merge request gitlab-org/gitlab-ce!29505
parents 76f49de4 8a3f0883
...@@ -33,7 +33,7 @@ export default { ...@@ -33,7 +33,7 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<gl-loading-icon v-if="isLoading" :size="3" /> <gl-loading-icon v-if="isLoading" size="md" />
<collapsible-container <collapsible-container
v-for="item in repos" v-for="item in repos"
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
<p v-else-if="!isLoading && !repos.length"> <p v-else-if="!isLoading && !repos.length">
{{ {{
__(`No container images stored for this project. __(`No container images stored for this project.
Add one by following the instructions above.`) Add one by following the instructions above.`)
}} }}
</p> </p>
</div> </div>
......
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlLoadingIcon, GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
import createFlash from '../../flash'; import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '../../vue_shared/components/icon.vue'; import Icon from '../../vue_shared/components/icon.vue';
...@@ -16,9 +16,11 @@ export default { ...@@ -16,9 +16,11 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlButton, GlButton,
Icon, Icon,
GlModal,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
}, },
props: { props: {
repo: { repo: {
...@@ -37,7 +39,7 @@ export default { ...@@ -37,7 +39,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteRepo']), ...mapActions(['fetchRepos', 'fetchList', 'deleteItem']),
toggleRepo() { toggleRepo() {
this.isOpen = !this.isOpen; this.isOpen = !this.isOpen;
...@@ -46,7 +48,7 @@ export default { ...@@ -46,7 +48,7 @@ export default {
} }
}, },
handleDeleteRepository() { handleDeleteRepository() {
this.deleteRepo(this.repo) this.deleteItem(this.repo)
.then(() => { .then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos(); this.fetchRepos();
...@@ -78,18 +80,18 @@ export default { ...@@ -78,18 +80,18 @@ export default {
<gl-button <gl-button
v-if="repo.canDelete" v-if="repo.canDelete"
v-gl-tooltip v-gl-tooltip
v-gl-modal="'confirm-repo-deletion-modal'"
:title="s__('ContainerRegistry|Remove repository')" :title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')"
class="js-remove-repo" class="js-remove-repo"
variant="danger" variant="danger"
@click="handleDeleteRepository"
> >
<icon name="remove" /> <icon name="remove" />
</gl-button> </gl-button>
</div> </div>
</div> </div>
<gl-loading-icon v-if="repo.isLoading" :size="2" class="append-bottom-20" /> <gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" />
<div v-else-if="!repo.isLoading && isOpen" class="container-image-tags"> <div v-else-if="!repo.isLoading && isOpen" class="container-image-tags">
<table-registry v-if="repo.list.length" :repo="repo" /> <table-registry v-if="repo.list.length" :repo="repo" />
...@@ -98,5 +100,24 @@ export default { ...@@ -98,5 +100,24 @@ export default {
{{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }} {{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }}
</div> </div>
</div> </div>
<gl-modal
modal-id="confirm-repo-deletion-modal"
ok-variant="danger"
@ok="handleDeleteRepository"
>
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p
v-html="
sprintf(
s__(
'ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted.',
),
{ title: repo.name },
)
"
></p>
<template v-slot:modal-ok>{{ __('Remove') }}</template>
</gl-modal>
</div> </div>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
import { n__ } from '../../locale'; import { n__ } from '../../locale';
import createFlash from '../../flash'; import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
...@@ -16,9 +16,11 @@ export default { ...@@ -16,9 +16,11 @@ export default {
TablePagination, TablePagination,
GlButton, GlButton,
Icon, Icon,
GlModal,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
}, },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
...@@ -27,21 +29,31 @@ export default { ...@@ -27,21 +29,31 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
itemToBeDeleted: null,
};
},
computed: { computed: {
shouldRenderPagination() { shouldRenderPagination() {
return this.repo.pagination.total > this.repo.pagination.perPage; return this.repo.pagination.total > this.repo.pagination.perPage;
}, },
}, },
methods: { methods: {
...mapActions(['fetchList', 'deleteRegistry']), ...mapActions(['fetchList', 'deleteItem']),
layers(item) { layers(item) {
return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; return item.layers ? n__('%d layer', '%d layers', item.layers) : '';
}, },
formatSize(size) { formatSize(size) {
return numberToHumanSize(size); return numberToHumanSize(size);
}, },
handleDeleteRegistry(registry) { setItemToBeDeleted(item) {
this.deleteRegistry(registry) this.itemToBeDeleted = item;
},
handleDeleteRegistry() {
const { itemToBeDeleted } = this;
this.itemToBeDeleted = null;
this.deleteItem(itemToBeDeleted)
.then(() => this.fetchList({ repo: this.repo })) .then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
}, },
...@@ -80,9 +92,9 @@ export default { ...@@ -80,9 +92,9 @@ export default {
/> />
</td> </td>
<td> <td>
<span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{ <span v-gl-tooltip.bottom class="monospace" :title="item.revision">
item.shortRevision {{ item.shortRevision }}
}}</span> </span>
</td> </td>
<td> <td>
{{ formatSize(item.size) }} {{ formatSize(item.size) }}
...@@ -93,20 +105,21 @@ export default { ...@@ -93,20 +105,21 @@ export default {
</td> </td>
<td> <td>
<span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{ <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">
timeFormated(item.createdAt) {{ timeFormated(item.createdAt) }}
}}</span> </span>
</td> </td>
<td class="content"> <td class="content">
<gl-button <gl-button
v-if="item.canDelete" v-if="item.canDelete"
v-gl-tooltip v-gl-tooltip
:title="s__('ContainerRegistry|Remove tag')" v-gl-modal="'confirm-image-deletion-modal'"
:aria-label="s__('ContainerRegistry|Remove tag')" :title="s__('ContainerRegistry|Remove image')"
:aria-label="s__('ContainerRegistry|Remove image')"
variant="danger" variant="danger"
class="js-delete-registry d-none d-sm-block float-right" class="js-delete-registry d-none d-sm-block float-right"
@click="handleDeleteRegistry(item)" @click="setItemToBeDeleted(item)"
> >
<icon name="remove" /> <icon name="remove" />
</gl-button> </gl-button>
...@@ -120,5 +133,24 @@ export default { ...@@ -120,5 +133,24 @@ export default {
:change="onPageChange" :change="onPageChange"
:page-info="repo.pagination" :page-info="repo.pagination"
/> />
<gl-modal
modal-id="confirm-image-deletion-modal"
ok-variant="danger"
@ok="handleDeleteRegistry"
>
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove image') }}</template>
<template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image and tags') }}</template>
<p
v-html="
sprintf(
s__(
'ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will delete the image and all tags pointing to this image.',
),
{ title: repo.name },
)
"
></p>
</gl-modal>
</div> </div>
</template> </template>
...@@ -35,11 +35,7 @@ export const fetchList = ({ commit }, { repo, page }) => { ...@@ -35,11 +35,7 @@ export const fetchList = ({ commit }, { repo, page }) => {
}); });
}; };
// eslint-disable-next-line no-unused-vars export const deleteItem = (_, item) => axios.delete(item.destroyPath);
export const deleteRepo = ({ commit }, repo) => axios.delete(repo.destroyPath);
// eslint-disable-next-line no-unused-vars
export const deleteRegistry = ({ commit }, image) => axios.delete(image.destroyPath);
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
......
---
title: Add confirmation for registry image deletion
merge_request: 29505
author:
type: added
...@@ -2849,10 +2849,13 @@ msgstr "" ...@@ -2849,10 +2849,13 @@ msgstr ""
msgid "ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands" msgid "ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands"
msgstr "" msgstr ""
msgid "ContainerRegistry|Remove repository" msgid "ContainerRegistry|Remove image"
msgstr ""
msgid "ContainerRegistry|Remove image and tags"
msgstr "" msgstr ""
msgid "ContainerRegistry|Remove tag" msgid "ContainerRegistry|Remove repository"
msgstr "" msgstr ""
msgid "ContainerRegistry|Size" msgid "ContainerRegistry|Size"
...@@ -2870,6 +2873,12 @@ msgstr "" ...@@ -2870,6 +2873,12 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images." msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr "" msgstr ""
msgid "ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will delete the image and all tags pointing to this image."
msgstr ""
msgid "ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted."
msgstr ""
msgid "ContainerRegistry|You can also use a %{deploy_token} for read-only access to the registry images." msgid "ContainerRegistry|You can also use a %{deploy_token} for read-only access to the registry images."
msgstr "" msgstr ""
......
...@@ -42,6 +42,8 @@ describe "Container Registry", :js do ...@@ -42,6 +42,8 @@ describe "Container Registry", :js do
.to receive(:delete_tags!).and_return(true) .to receive(:delete_tags!).and_return(true)
click_on(class: 'js-remove-repo') click_on(class: 'js-remove-repo')
expect(find('.modal .modal-title')).to have_content 'Remove repository'
find('.modal .modal-footer .btn-danger').click
end end
it 'user removes a specific tag from container repository' do it 'user removes a specific tag from container repository' do
...@@ -54,6 +56,8 @@ describe "Container Registry", :js do ...@@ -54,6 +56,8 @@ describe "Container Registry", :js do
.to receive(:delete).and_return(true) .to receive(:delete).and_return(true)
click_on(class: 'js-delete-registry') click_on(class: 'js-delete-registry')
expect(find('.modal .modal-title')).to have_content 'Remove image'
find('.modal .modal-footer .btn-danger').click
end end
end end
......
...@@ -12,6 +12,8 @@ describe('collapsible registry container', () => { ...@@ -12,6 +12,8 @@ describe('collapsible registry container', () => {
let mock; let mock;
const Component = Vue.extend(collapsibleComponent); const Component = Vue.extend(collapsibleComponent);
const findDeleteBtn = () => vm.$el.querySelector('.js-remove-repo');
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
...@@ -67,7 +69,25 @@ describe('collapsible registry container', () => { ...@@ -67,7 +69,25 @@ describe('collapsible registry container', () => {
describe('delete repo', () => { describe('delete repo', () => {
it('should be possible to delete a repo', () => { it('should be possible to delete a repo', () => {
expect(vm.$el.querySelector('.js-remove-repo')).not.toBeNull(); expect(findDeleteBtn()).not.toBeNull();
});
describe('clicked on delete', () => {
beforeEach(done => {
findDeleteBtn().click();
Vue.nextTick(done);
});
it('should open confirmation modal', () => {
expect(vm.$el.querySelector('#confirm-repo-deletion-modal')).not.toBeNull();
});
it('should call deleteItem when confirming deletion', () => {
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());
vm.$el.querySelector('#confirm-repo-deletion-modal .btn-danger').click();
expect(vm.deleteItem).toHaveBeenCalledWith(vm.repo);
});
}); });
}); });
}); });
...@@ -3,10 +3,14 @@ import tableRegistry from '~/registry/components/table_registry.vue'; ...@@ -3,10 +3,14 @@ import tableRegistry from '~/registry/components/table_registry.vue';
import store from '~/registry/stores'; import store from '~/registry/stores';
import { repoPropsData } from '../mock_data'; import { repoPropsData } from '../mock_data';
const [firstImage] = repoPropsData.list;
describe('table registry', () => { describe('table registry', () => {
let vm; let vm;
let Component; let Component;
const findDeleteBtn = () => vm.$el.querySelector('.js-delete-registry');
beforeEach(() => { beforeEach(() => {
Component = Vue.extend(tableRegistry); Component = Vue.extend(tableRegistry);
vm = new Component({ vm = new Component({
...@@ -37,8 +41,30 @@ describe('table registry', () => { ...@@ -37,8 +41,30 @@ describe('table registry', () => {
expect(textRendered).toContain(repoPropsData.list[0].size); expect(textRendered).toContain(repoPropsData.list[0].size);
}); });
it('should be possible to delete a registry', () => { describe('delete registry', () => {
expect(vm.$el.querySelector('.table tbody tr .js-delete-registry')).toBeDefined(); it('should be possible to delete a registry', () => {
expect(findDeleteBtn()).toBeDefined();
});
describe('clicked on delete', () => {
beforeEach(done => {
findDeleteBtn().click();
Vue.nextTick(done);
});
it('should open confirmation modal and set itemToBeDeleted properly', () => {
expect(vm.itemToBeDeleted).toEqual(firstImage);
expect(vm.$el.querySelector('#confirm-image-deletion-modal')).not.toBeNull();
});
it('should call deleteItem and reset itemToBeDeleted when confirming deletion', () => {
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());
vm.$el.querySelector('#confirm-image-deletion-modal .btn-danger').click();
expect(vm.deleteItem).toHaveBeenCalledWith(firstImage);
expect(vm.itemToBeDeleted).toBeNull();
});
});
}); });
describe('pagination', () => { describe('pagination', () => {
......
...@@ -105,4 +105,28 @@ describe('Actions Registry Store', () => { ...@@ -105,4 +105,28 @@ describe('Actions Registry Store', () => {
); );
}); });
}); });
describe('deleteItem', () => {
it('should perform DELETE request on destroyPath', done => {
const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`;
let deleted = false;
mock.onDelete(destroyPath).replyOnce(() => {
deleted = true;
return [200];
});
testAction(
actions.deleteItem,
{
destroyPath,
},
mockedState,
)
.then(() => {
expect(mock.history.delete.length).toBe(1);
expect(deleted).toBe(true);
done();
})
.catch(done.fail);
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment