Implement multi select deletion for container registry
Added checkboxes to each image row Added delete selected images button Changed row delete button to appear on row hover Changed confirmation modal message Changed delete logic to support multi Added tests for multi select Updated pot file Updated rspec test for new functionality
This commit is contained in:
parent
9ba87676c8
commit
51b04b5f22
7 changed files with 283 additions and 47 deletions
|
@ -84,7 +84,7 @@ export default {
|
|||
v-gl-modal="modalId"
|
||||
:title="s__('ContainerRegistry|Remove repository')"
|
||||
:aria-label="s__('ContainerRegistry|Remove repository')"
|
||||
class="js-remove-repo"
|
||||
class="js-remove-repo btn-inverted"
|
||||
variant="danger"
|
||||
>
|
||||
<icon name="remove" />
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import { GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
|
||||
import { n__ } from '../../locale';
|
||||
import {
|
||||
GlButton,
|
||||
GlFormCheckbox,
|
||||
GlTooltipDirective,
|
||||
GlModal,
|
||||
GlModalDirective,
|
||||
} from '@gitlab/ui';
|
||||
import { n__, s__, sprintf } from '../../locale';
|
||||
import createFlash from '../../flash';
|
||||
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
|
||||
import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
|
||||
|
@ -14,6 +20,7 @@ export default {
|
|||
components: {
|
||||
ClipboardButton,
|
||||
TablePagination,
|
||||
GlFormCheckbox,
|
||||
GlButton,
|
||||
Icon,
|
||||
GlModal,
|
||||
|
@ -31,14 +38,44 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
itemToBeDeleted: null,
|
||||
singleItemToBeDeleted: null,
|
||||
itemsToBeDeleted: [],
|
||||
modalId: `confirm-image-deletion-modal-${this.repo.id}`,
|
||||
selectAllChecked: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
shouldRenderPagination() {
|
||||
return this.repo.pagination.total > this.repo.pagination.perPage;
|
||||
},
|
||||
modalTitle() {
|
||||
if (this.singleItemToBeDeleted !== null || this.itemsToBeDeleted.length === 1) {
|
||||
return s__('ContainerRegistry|Remove image');
|
||||
}
|
||||
return s__('ContainerRegistry|Remove images');
|
||||
},
|
||||
modalDescription() {
|
||||
const selectedCount = this.itemsToBeDeleted.length;
|
||||
|
||||
if (this.singleItemToBeDeleted !== null || selectedCount === 1) {
|
||||
const { tag } =
|
||||
this.singleItemToBeDeleted !== null
|
||||
? this.repo.list[this.singleItemToBeDeleted]
|
||||
: this.repo.list[this.itemsToBeDeleted[0]];
|
||||
|
||||
return 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: `${this.repo.name}:${tag}` },
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
s__(`ContainerRegistry|You are about to delete <b>%{count}</b> images. This will
|
||||
delete the images and all tags pointing to them.`),
|
||||
{ count: selectedCount },
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchList', 'deleteItem']),
|
||||
|
@ -48,13 +85,32 @@ export default {
|
|||
formatSize(size) {
|
||||
return numberToHumanSize(size);
|
||||
},
|
||||
setItemToBeDeleted(item) {
|
||||
this.itemToBeDeleted = item;
|
||||
setSingleItemToBeDeleted(idx) {
|
||||
this.singleItemToBeDeleted = idx;
|
||||
},
|
||||
resetSingleItemToBeDeleted() {
|
||||
this.singleItemToBeDeleted = null;
|
||||
},
|
||||
handleDeleteRegistry() {
|
||||
const { itemToBeDeleted } = this;
|
||||
this.itemToBeDeleted = null;
|
||||
this.deleteItem(itemToBeDeleted)
|
||||
let { itemsToBeDeleted } = this;
|
||||
this.itemsToBeDeleted = [];
|
||||
|
||||
if (this.singleItemToBeDeleted !== null) {
|
||||
const { singleItemToBeDeleted } = this;
|
||||
this.singleItemToBeDeleted = null;
|
||||
itemsToBeDeleted = [singleItemToBeDeleted];
|
||||
}
|
||||
|
||||
const deleteActions = itemsToBeDeleted.map(
|
||||
x =>
|
||||
new Promise((resolve, reject) => {
|
||||
this.deleteItem(this.repo.list[x])
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}),
|
||||
);
|
||||
|
||||
Promise.all(deleteActions)
|
||||
.then(() => this.fetchList({ repo: this.repo }))
|
||||
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
|
||||
},
|
||||
|
@ -66,6 +122,29 @@ export default {
|
|||
showError(message) {
|
||||
createFlash(errorMessages[message]);
|
||||
},
|
||||
selectAll() {
|
||||
if (!this.selectAllChecked) {
|
||||
this.itemsToBeDeleted = this.repo.list.map((x, idx) => idx);
|
||||
this.selectAllChecked = true;
|
||||
} else {
|
||||
this.itemsToBeDeleted = [];
|
||||
this.selectAllChecked = false;
|
||||
}
|
||||
},
|
||||
updateItemsToBeDeleted(idx) {
|
||||
const delIdx = this.itemsToBeDeleted.findIndex(x => x === idx);
|
||||
|
||||
if (delIdx > -1) {
|
||||
this.itemsToBeDeleted.splice(delIdx, 1);
|
||||
this.selectAllChecked = false;
|
||||
} else {
|
||||
this.itemsToBeDeleted.push(idx);
|
||||
|
||||
if (this.itemsToBeDeleted.length === this.repo.list.length) {
|
||||
this.selectAllChecked = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -74,15 +153,43 @@ export default {
|
|||
<table class="table tags">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<gl-form-checkbox
|
||||
v-if="repo.canDelete"
|
||||
class="js-select-all-checkbox"
|
||||
:checked="selectAllChecked"
|
||||
@change="selectAll"
|
||||
/>
|
||||
</th>
|
||||
<th>{{ s__('ContainerRegistry|Tag') }}</th>
|
||||
<th>{{ s__('ContainerRegistry|Tag ID') }}</th>
|
||||
<th>{{ s__('ContainerRegistry|Size') }}</th>
|
||||
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
|
||||
<th></th>
|
||||
<th>
|
||||
<gl-button
|
||||
v-if="repo.canDelete"
|
||||
v-gl-tooltip
|
||||
v-gl-modal="modalId"
|
||||
:disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0"
|
||||
class="js-delete-registry float-right"
|
||||
variant="danger"
|
||||
:title="s__('ContainerRegistry|Remove selected images')"
|
||||
:aria-label="s__('ContainerRegistry|Remove selected images')"
|
||||
><icon name="remove"
|
||||
/></gl-button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in repo.list" :key="item.tag">
|
||||
<tr v-for="(item, idx) in repo.list" :key="item.tag">
|
||||
<td class="check">
|
||||
<gl-form-checkbox
|
||||
v-if="item.canDelete"
|
||||
class="js-select-checkbox"
|
||||
:checked="itemsToBeDeleted && itemsToBeDeleted.includes(idx)"
|
||||
@change="updateItemsToBeDeleted(idx)"
|
||||
/>
|
||||
</td>
|
||||
<td class="monospace">
|
||||
{{ item.tag }}
|
||||
<clipboard-button
|
||||
|
@ -111,16 +218,15 @@ export default {
|
|||
</span>
|
||||
</td>
|
||||
|
||||
<td class="content">
|
||||
<td class="content action-buttons">
|
||||
<gl-button
|
||||
v-if="item.canDelete"
|
||||
v-gl-tooltip
|
||||
v-gl-modal="modalId"
|
||||
:title="s__('ContainerRegistry|Remove image')"
|
||||
:aria-label="s__('ContainerRegistry|Remove image')"
|
||||
variant="danger"
|
||||
class="js-delete-registry d-none d-sm-block float-right"
|
||||
@click="setItemToBeDeleted(item)"
|
||||
class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
|
||||
@click="setSingleItemToBeDeleted(idx)"
|
||||
>
|
||||
<icon name="remove" />
|
||||
</gl-button>
|
||||
|
@ -135,19 +241,15 @@ export default {
|
|||
:page-info="repo.pagination"
|
||||
/>
|
||||
|
||||
<gl-modal :modal-id="modalId" 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
|
||||
:modal-id="modalId"
|
||||
ok-variant="danger"
|
||||
@ok="handleDeleteRegistry"
|
||||
@cancel="resetSingleItemToBeDeleted"
|
||||
>
|
||||
<template v-slot:modal-title>{{ modalTitle }}</template>
|
||||
<template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template>
|
||||
<p v-html="modalDescription"></p>
|
||||
</gl-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -31,4 +31,27 @@
|
|||
|
||||
.table.tags {
|
||||
margin-bottom: 0;
|
||||
|
||||
th {
|
||||
height: 55px;
|
||||
}
|
||||
|
||||
tr {
|
||||
&:hover {
|
||||
td {
|
||||
&.action-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td.check {
|
||||
padding-right: $gl-padding;
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
td.action-buttons {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3132,12 +3132,18 @@ msgstr ""
|
|||
msgid "ContainerRegistry|Remove image"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Remove image and tags"
|
||||
msgid "ContainerRegistry|Remove image(s) and tags"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Remove images"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Remove repository"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Remove selected images"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Size"
|
||||
msgstr ""
|
||||
|
||||
|
@ -3159,6 +3165,9 @@ msgstr ""
|
|||
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|You are about to delete <b>%{count}</b> images. This will delete the images and all tags pointing to them."
|
||||
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 ""
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
describe "Container Registry", :js do
|
||||
describe 'Container Registry', :js do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project) }
|
||||
|
||||
|
@ -40,8 +40,7 @@ describe "Container Registry", :js do
|
|||
it 'user removes entire container repository' do
|
||||
visit_container_registry
|
||||
|
||||
expect_any_instance_of(ContainerRepository)
|
||||
.to receive(:delete_tags!).and_return(true)
|
||||
expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true)
|
||||
|
||||
click_on(class: 'js-remove-repo')
|
||||
expect(find('.modal .modal-title')).to have_content 'Remove repository'
|
||||
|
@ -54,10 +53,9 @@ describe "Container Registry", :js do
|
|||
find('.js-toggle-repo').click
|
||||
wait_for_requests
|
||||
|
||||
expect_any_instance_of(ContainerRegistry::Tag)
|
||||
.to receive(:delete).and_return(true)
|
||||
expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete).and_return(true)
|
||||
|
||||
click_on(class: 'js-delete-registry')
|
||||
click_on(class: 'js-delete-registry-row', visible: false)
|
||||
expect(find('.modal .modal-title')).to have_content 'Remove image'
|
||||
find('.modal .modal-footer .btn-danger').click
|
||||
end
|
||||
|
|
|
@ -3,15 +3,19 @@ import tableRegistry from '~/registry/components/table_registry.vue';
|
|||
import store from '~/registry/stores';
|
||||
import { repoPropsData } from '../mock_data';
|
||||
|
||||
const [firstImage] = repoPropsData.list;
|
||||
const [firstImage, secondImage] = repoPropsData.list;
|
||||
|
||||
describe('table registry', () => {
|
||||
let vm;
|
||||
let Component;
|
||||
|
||||
const findDeleteBtn = () => vm.$el.querySelector('.js-delete-registry');
|
||||
const findDeleteBtnRow = () => vm.$el.querySelector('.js-delete-registry-row');
|
||||
const findSelectAllCheckbox = () => vm.$el.querySelector('.js-select-all-checkbox > input');
|
||||
const findAllRowCheckboxes = () =>
|
||||
Array.from(vm.$el.querySelectorAll('.js-select-checkbox input'));
|
||||
|
||||
beforeEach(() => {
|
||||
const createComponent = () => {
|
||||
Component = Vue.extend(tableRegistry);
|
||||
vm = new Component({
|
||||
store,
|
||||
|
@ -19,6 +23,10 @@ describe('table registry', () => {
|
|||
repo: repoPropsData,
|
||||
},
|
||||
}).$mount();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -41,23 +49,108 @@ describe('table registry', () => {
|
|||
expect(textRendered).toContain(repoPropsData.list[0].size);
|
||||
});
|
||||
|
||||
describe('delete registry', () => {
|
||||
it('should be possible to delete a registry', () => {
|
||||
expect(findDeleteBtn()).toBeDefined();
|
||||
describe('multi select', () => {
|
||||
beforeEach(() => {
|
||||
vm.itemsToBeDeleted = [];
|
||||
});
|
||||
|
||||
it('should call deleteItem and reset itemToBeDeleted when confirming deletion', done => {
|
||||
findDeleteBtn().click();
|
||||
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());
|
||||
it('should support multiselect and selecting a row should enable delete button', done => {
|
||||
findSelectAllCheckbox().click();
|
||||
|
||||
vm.selectAll();
|
||||
|
||||
expect(findSelectAllCheckbox().checked).toBe(true);
|
||||
|
||||
Vue.nextTick(() => {
|
||||
document.querySelector(`#${vm.modalId} .btn-danger`).click();
|
||||
|
||||
expect(vm.deleteItem).toHaveBeenCalledWith(firstImage);
|
||||
expect(vm.itemToBeDeleted).toBeNull();
|
||||
expect(findDeleteBtn().disabled).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('selecting all checkbox should select all rows and enable delete button', done => {
|
||||
findSelectAllCheckbox().click();
|
||||
vm.selectAll();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
const checkedValues = findAllRowCheckboxes().filter(x => x.checked);
|
||||
|
||||
expect(checkedValues.length).toBe(repoPropsData.list.length);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('deselecting select all checkbox should deselect all rows and disable delete button', done => {
|
||||
findSelectAllCheckbox().click();
|
||||
vm.selectAll(); // Select them all on
|
||||
vm.selectAll(); // Select them all off
|
||||
|
||||
Vue.nextTick(() => {
|
||||
const checkedValues = findAllRowCheckboxes().filter(x => x.checked);
|
||||
|
||||
expect(checkedValues.length).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete multiple items when multiple items are selected', done => {
|
||||
findSelectAllCheckbox().click();
|
||||
vm.selectAll();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.itemsToBeDeleted).toEqual([0, 1]);
|
||||
expect(findDeleteBtn().disabled).toBe(false);
|
||||
|
||||
findDeleteBtn().click();
|
||||
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());
|
||||
|
||||
Vue.nextTick(() => {
|
||||
const modal = document.querySelector(`#${vm.modalId}`);
|
||||
document.querySelector(`#${vm.modalId} .btn-danger`).click();
|
||||
|
||||
expect(modal).toExist();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.itemsToBeDeleted).toEqual([]);
|
||||
expect(vm.deleteItem).toHaveBeenCalledWith(firstImage);
|
||||
expect(vm.deleteItem).toHaveBeenCalledWith(secondImage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete registry', () => {
|
||||
beforeEach(() => {
|
||||
vm.itemsToBeDeleted = [0];
|
||||
});
|
||||
|
||||
it('should be possible to delete a registry', done => {
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.itemsToBeDeleted).toEqual([0]);
|
||||
expect(findDeleteBtn()).toBeDefined();
|
||||
expect(findDeleteBtn().disabled).toBe(false);
|
||||
expect(findDeleteBtnRow()).toBeDefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call deleteItem and reset itemsToBeDeleted when confirming deletion', done => {
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.itemsToBeDeleted).toEqual([0]);
|
||||
expect(findDeleteBtn().disabled).toBe(false);
|
||||
findDeleteBtn().click();
|
||||
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());
|
||||
|
||||
Vue.nextTick(() => {
|
||||
document.querySelector(`#${vm.modalId} .btn-danger`).click();
|
||||
|
||||
expect(vm.itemsToBeDeleted).toEqual([]);
|
||||
expect(vm.deleteItem).toHaveBeenCalledWith(firstImage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
|
|
|
@ -108,6 +108,17 @@ export const repoPropsData = {
|
|||
destroyPath: 'path',
|
||||
canDelete: true,
|
||||
},
|
||||
{
|
||||
tag: 'test-image',
|
||||
revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4',
|
||||
shortRevision: 'b969de599',
|
||||
size: 19,
|
||||
layers: 10,
|
||||
location: 'location-2',
|
||||
createdAt: 1505828744434,
|
||||
destroyPath: 'path-2',
|
||||
canDelete: true,
|
||||
},
|
||||
],
|
||||
location: 'location',
|
||||
name: 'foo',
|
||||
|
|
Loading…
Reference in a new issue