Commit 51b04b5f authored by Nick Kipling's avatar Nick Kipling Committed by Nathan Friend

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
parent 9ba87676
...@@ -84,7 +84,7 @@ export default { ...@@ -84,7 +84,7 @@ export default {
v-gl-modal="modalId" v-gl-modal="modalId"
: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 btn-inverted"
variant="danger" variant="danger"
> >
<icon name="remove" /> <icon name="remove" />
......
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui'; import {
import { n__ } from '../../locale'; GlButton,
GlFormCheckbox,
GlTooltipDirective,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import { n__, s__, sprintf } 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';
import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
...@@ -14,6 +20,7 @@ export default { ...@@ -14,6 +20,7 @@ export default {
components: { components: {
ClipboardButton, ClipboardButton,
TablePagination, TablePagination,
GlFormCheckbox,
GlButton, GlButton,
Icon, Icon,
GlModal, GlModal,
...@@ -31,14 +38,44 @@ export default { ...@@ -31,14 +38,44 @@ export default {
}, },
data() { data() {
return { return {
itemToBeDeleted: null, singleItemToBeDeleted: null,
itemsToBeDeleted: [],
modalId: `confirm-image-deletion-modal-${this.repo.id}`, modalId: `confirm-image-deletion-modal-${this.repo.id}`,
selectAllChecked: false,
}; };
}, },
computed: { computed: {
shouldRenderPagination() { shouldRenderPagination() {
return this.repo.pagination.total > this.repo.pagination.perPage; 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: { methods: {
...mapActions(['fetchList', 'deleteItem']), ...mapActions(['fetchList', 'deleteItem']),
...@@ -48,13 +85,32 @@ export default { ...@@ -48,13 +85,32 @@ export default {
formatSize(size) { formatSize(size) {
return numberToHumanSize(size); return numberToHumanSize(size);
}, },
setItemToBeDeleted(item) { setSingleItemToBeDeleted(idx) {
this.itemToBeDeleted = item; this.singleItemToBeDeleted = idx;
},
resetSingleItemToBeDeleted() {
this.singleItemToBeDeleted = null;
}, },
handleDeleteRegistry() { handleDeleteRegistry() {
const { itemToBeDeleted } = this; let { itemsToBeDeleted } = this;
this.itemToBeDeleted = null; this.itemsToBeDeleted = [];
this.deleteItem(itemToBeDeleted)
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 })) .then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
}, },
...@@ -66,6 +122,29 @@ export default { ...@@ -66,6 +122,29 @@ export default {
showError(message) { showError(message) {
createFlash(errorMessages[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> </script>
...@@ -74,15 +153,43 @@ export default { ...@@ -74,15 +153,43 @@ export default {
<table class="table tags"> <table class="table tags">
<thead> <thead>
<tr> <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') }}</th>
<th>{{ s__('ContainerRegistry|Tag ID') }}</th> <th>{{ s__('ContainerRegistry|Tag ID') }}</th>
<th>{{ s__('ContainerRegistry|Size') }}</th> <th>{{ s__('ContainerRegistry|Size') }}</th>
<th>{{ s__('ContainerRegistry|Last Updated') }}</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> </tr>
</thead> </thead>
<tbody> <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"> <td class="monospace">
{{ item.tag }} {{ item.tag }}
<clipboard-button <clipboard-button
...@@ -111,16 +218,15 @@ export default { ...@@ -111,16 +218,15 @@ export default {
</span> </span>
</td> </td>
<td class="content"> <td class="content action-buttons">
<gl-button <gl-button
v-if="item.canDelete" v-if="item.canDelete"
v-gl-tooltip
v-gl-modal="modalId" v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove image')" :title="s__('ContainerRegistry|Remove image')"
:aria-label="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-row float-right btn-inverted btn-border-color btn-icon"
@click="setItemToBeDeleted(item)" @click="setSingleItemToBeDeleted(idx)"
> >
<icon name="remove" /> <icon name="remove" />
</gl-button> </gl-button>
...@@ -135,19 +241,15 @@ export default { ...@@ -135,19 +241,15 @@ export default {
:page-info="repo.pagination" :page-info="repo.pagination"
/> />
<gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRegistry"> <gl-modal
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove image') }}</template> :modal-id="modalId"
<template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image and tags') }}</template> ok-variant="danger"
<p @ok="handleDeleteRegistry"
v-html=" @cancel="resetSingleItemToBeDeleted"
sprintf( >
s__( <template v-slot:modal-title>{{ modalTitle }}</template>
'ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will delete the image and all tags pointing to this image.', <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template>
), <p v-html="modalDescription"></p>
{ title: repo.name },
)
"
></p>
</gl-modal> </gl-modal>
</div> </div>
</template> </template>
...@@ -31,4 +31,27 @@ ...@@ -31,4 +31,27 @@
.table.tags { .table.tags {
margin-bottom: 0; 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 "" ...@@ -3132,12 +3132,18 @@ msgstr ""
msgid "ContainerRegistry|Remove image" msgid "ContainerRegistry|Remove image"
msgstr "" msgstr ""
msgid "ContainerRegistry|Remove image and tags" msgid "ContainerRegistry|Remove image(s) and tags"
msgstr ""
msgid "ContainerRegistry|Remove images"
msgstr "" msgstr ""
msgid "ContainerRegistry|Remove repository" msgid "ContainerRegistry|Remove repository"
msgstr "" msgstr ""
msgid "ContainerRegistry|Remove selected images"
msgstr ""
msgid "ContainerRegistry|Size" msgid "ContainerRegistry|Size"
msgstr "" msgstr ""
...@@ -3159,6 +3165,9 @@ 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}" 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 "" 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." 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 "" msgstr ""
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe "Container Registry", :js do describe 'Container Registry', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
...@@ -40,8 +40,7 @@ describe "Container Registry", :js do ...@@ -40,8 +40,7 @@ describe "Container Registry", :js do
it 'user removes entire container repository' do it 'user removes entire container repository' do
visit_container_registry visit_container_registry
expect_any_instance_of(ContainerRepository) expect_any_instance_of(ContainerRepository).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' expect(find('.modal .modal-title')).to have_content 'Remove repository'
...@@ -54,10 +53,9 @@ describe "Container Registry", :js do ...@@ -54,10 +53,9 @@ describe "Container Registry", :js do
find('.js-toggle-repo').click find('.js-toggle-repo').click
wait_for_requests wait_for_requests
expect_any_instance_of(ContainerRegistry::Tag) expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete).and_return(true)
.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' expect(find('.modal .modal-title')).to have_content 'Remove image'
find('.modal .modal-footer .btn-danger').click find('.modal .modal-footer .btn-danger').click
end end
......
...@@ -3,15 +3,19 @@ import tableRegistry from '~/registry/components/table_registry.vue'; ...@@ -3,15 +3,19 @@ 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; const [firstImage, secondImage] = repoPropsData.list;
describe('table registry', () => { describe('table registry', () => {
let vm; let vm;
let Component; let Component;
const findDeleteBtn = () => vm.$el.querySelector('.js-delete-registry'); 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); Component = Vue.extend(tableRegistry);
vm = new Component({ vm = new Component({
store, store,
...@@ -19,6 +23,10 @@ describe('table registry', () => { ...@@ -19,6 +23,10 @@ describe('table registry', () => {
repo: repoPropsData, repo: repoPropsData,
}, },
}).$mount(); }).$mount();
};
beforeEach(() => {
createComponent();
}); });
afterEach(() => { afterEach(() => {
...@@ -41,24 +49,109 @@ describe('table registry', () => { ...@@ -41,24 +49,109 @@ describe('table registry', () => {
expect(textRendered).toContain(repoPropsData.list[0].size); expect(textRendered).toContain(repoPropsData.list[0].size);
}); });
describe('multi select', () => {
beforeEach(() => {
vm.itemsToBeDeleted = [];
});
it('should support multiselect and selecting a row should enable delete button', done => {
findSelectAllCheckbox().click();
vm.selectAll();
expect(findSelectAllCheckbox().checked).toBe(true);
Vue.nextTick(() => {
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', () => { describe('delete registry', () => {
it('should be possible to delete a 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()).toBeDefined();
expect(findDeleteBtn().disabled).toBe(false);
expect(findDeleteBtnRow()).toBeDefined();
done();
});
}); });
it('should call deleteItem and reset itemToBeDeleted when confirming deletion', 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(); findDeleteBtn().click();
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve()); spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());
Vue.nextTick(() => { Vue.nextTick(() => {
document.querySelector(`#${vm.modalId} .btn-danger`).click(); document.querySelector(`#${vm.modalId} .btn-danger`).click();
expect(vm.itemsToBeDeleted).toEqual([]);
expect(vm.deleteItem).toHaveBeenCalledWith(firstImage); expect(vm.deleteItem).toHaveBeenCalledWith(firstImage);
expect(vm.itemToBeDeleted).toBeNull();
done(); done();
}); });
}); });
}); });
});
describe('pagination', () => { describe('pagination', () => {
it('should be possible to change the page', () => { it('should be possible to change the page', () => {
......
...@@ -108,6 +108,17 @@ export const repoPropsData = { ...@@ -108,6 +108,17 @@ export const repoPropsData = {
destroyPath: 'path', destroyPath: 'path',
canDelete: true, 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', location: 'location',
name: 'foo', name: 'foo',
......
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