diff options
-rw-r--r-- | app/assets/javascripts/registry/components/collapsible_container.vue | 2 | ||||
-rw-r--r-- | app/assets/javascripts/registry/components/table_registry.vue | 156 | ||||
-rw-r--r-- | app/assets/stylesheets/pages/container_registry.scss | 23 | ||||
-rw-r--r-- | locale/gitlab.pot | 11 | ||||
-rw-r--r-- | spec/features/container_registry_spec.rb | 10 | ||||
-rw-r--r-- | spec/javascripts/registry/components/table_registry_spec.js | 115 | ||||
-rw-r--r-- | spec/javascripts/registry/mock_data.js | 11 |
7 files changed, 282 insertions, 46 deletions
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index e157036871b..bfb2305c48c 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -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" /> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index a498a553908..a241db13e5a 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -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> diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index a21fa29f34a..ed9de6f7e30 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -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; + } + } } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 591dc2a7e39..557a8f2681e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index 89dece97a35..aefdc4d6d4f 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -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 diff --git a/spec/javascripts/registry/components/table_registry_spec.js b/spec/javascripts/registry/components/table_registry_spec.js index 31ac970378e..9ee326325e0 100644 --- a/spec/javascripts/registry/components/table_registry_spec.js +++ b/spec/javascripts/registry/components/table_registry_spec.js @@ -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 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('should call deleteItem and reset itemToBeDeleted when confirming deletion', done => { - findDeleteBtn().click(); - spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve()); + it('selecting all checkbox should select all rows and enable delete button', done => { + findSelectAllCheckbox().click(); + vm.selectAll(); Vue.nextTick(() => { - document.querySelector(`#${vm.modalId} .btn-danger`).click(); + const checkedValues = findAllRowCheckboxes().filter(x => x.checked); - expect(vm.deleteItem).toHaveBeenCalledWith(firstImage); - expect(vm.itemToBeDeleted).toBeNull(); + 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', () => { diff --git a/spec/javascripts/registry/mock_data.js b/spec/javascripts/registry/mock_data.js index 22db203e77f..130ab298e89 100644 --- a/spec/javascripts/registry/mock_data.js +++ b/spec/javascripts/registry/mock_data.js @@ -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', |