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" > diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index a498a553908..e9067bc2b56 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,7 +1,13 @@ @@ -74,15 +175,44 @@ export default { + - + - + + -
+ + {{ s__('ContainerRegistry|Tag') }} {{ s__('ContainerRegistry|Tag ID') }} {{ s__('ContainerRegistry|Size') }} {{ s__('ContainerRegistry|Last Updated') }} + +
+ + {{ item.tag }} + @@ -135,19 +264,10 @@ export default { :page-info="repo.pagination" /> - - - -

+ + + +

diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index 0f5e9cc73a0..a2e0130e79e 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -36,6 +36,8 @@ export const fetchList = ({ commit }, { repo, page }) => { }; export const deleteItem = (_, item) => axios.delete(item.destroyPath); +export const multiDeleteItems = (_, { path, items }) => + axios.delete(path, { params: { ids: items } }); export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index a21fa29f34a..0f4bdb219a3 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -31,4 +31,21 @@ .table.tags { margin-bottom: 0; + + .registry-image-row { + .check { + padding-right: $gl-padding; + width: 5%; + } + + .action-buttons { + opacity: 0; + } + + &:hover { + .action-buttons { + opacity: 1; + } + } + } } diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index bf1d8d8b5fc..54e2faa2dd7 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -5,6 +5,8 @@ module Projects class TagsController < ::Projects::Registry::ApplicationController before_action :authorize_destroy_container_image!, only: [:destroy] + LIMIT = 15 + def index respond_to do |format| format.json do @@ -28,10 +30,40 @@ module Projects end end + def bulk_destroy + unless params[:ids].present? + head :bad_request + return + end + + tag_names = params[:ids] || [] + if tag_names.size > LIMIT + head :bad_request + return + end + + @tags = tag_names.map { |tag_name| image.tag(tag_name) } + unless @tags.all? { |tag| tag.valid_name? } + head :bad_request + return + end + + success_count = 0 + @tags.each do |tag| + if tag.delete + success_count += 1 + end + end + + respond_to do |format| + format.json { head(success_count == @tags.size ? :no_content : :bad_request) } + end + end + private def tags - Kaminari::PaginatableArray.new(image.tags, limit: 15) + Kaminari::PaginatableArray.new(image.tags, limit: LIMIT) end def image diff --git a/changelogs/unreleased/24705-multi-selection-for-delete-on-registry-page.yml b/changelogs/unreleased/24705-multi-selection-for-delete-on-registry-page.yml new file mode 100644 index 00000000000..5254bd36b9c --- /dev/null +++ b/changelogs/unreleased/24705-multi-selection-for-delete-on-registry-page.yml @@ -0,0 +1,5 @@ +--- +title: Added multi-select deletion of container registry images +merge_request: 30837 +author: +type: other diff --git a/config/routes/project.rb b/config/routes/project.rb index a207ee44d47..9a453d101a1 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -477,7 +477,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do # in JSON format, or a request for tag named `latest.json`. scope format: false do resources :tags, only: [:index, :destroy], - constraints: { id: Gitlab::Regex.container_registry_tag_regex } + constraints: { id: Gitlab::Regex.container_registry_tag_regex } do + collection do + delete :bulk_destroy + end + end end end end diff --git a/lib/container_registry/tag.rb b/lib/container_registry/tag.rb index ef41dc560c9..ebea84fa1ca 100644 --- a/lib/container_registry/tag.rb +++ b/lib/container_registry/tag.rb @@ -6,6 +6,9 @@ module ContainerRegistry attr_reader :repository, :name + # https://github.com/docker/distribution/commit/3150937b9f2b1b5b096b2634d0e7c44d4a0f89fb + TAG_NAME_REGEX = /^[\w][\w.-]{0,127}$/.freeze + delegate :registry, :client, to: :repository delegate :revision, :short_revision, to: :config_blob, allow_nil: true @@ -13,6 +16,10 @@ module ContainerRegistry @repository, @name = repository, name end + def valid_name? + !name.match(TAG_NAME_REGEX).nil? + end + def valid? manifest.present? end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index dd69fa1f8f6..6227ca9afc3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3178,14 +3178,19 @@ msgid "ContainerRegistry|Quick Start" msgstr "" msgid "ContainerRegistry|Remove image" -msgstr "" +msgid_plural "ContainerRegistry|Remove images" +msgstr[0] "" +msgstr[1] "" -msgid "ContainerRegistry|Remove image and tags" +msgid "ContainerRegistry|Remove image(s) and tags" msgstr "" msgid "ContainerRegistry|Remove repository" msgstr "" +msgid "ContainerRegistry|Remove selected images" +msgstr "" + msgid "ContainerRegistry|Size" msgstr "" @@ -3207,6 +3212,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 %{count} images. This will delete the images and all tags pointing to them." +msgstr "" + msgid "ContainerRegistry|You are about to delete the image %{title}. This will delete the image and all tags pointing to this image." msgstr "" diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb index ff35139ae2e..c6e063d8229 100644 --- a/spec/controllers/projects/registry/tags_controller_spec.rb +++ b/spec/controllers/projects/registry/tags_controller_spec.rb @@ -113,4 +113,37 @@ describe Projects::Registry::TagsController do format: :json end end + + describe 'POST bulk_destroy' do + context 'when user has access to registry' do + before do + project.add_developer(user) + end + + context 'when there is matching tag present' do + before do + stub_container_registry_tags(repository: repository.path, tags: %w[rc1 test.]) + end + + it 'makes it possible to delete tags in bulk' do + allow_any_instance_of(ContainerRegistry::Tag).to receive(:delete) { |*args| ContainerRegistry::Tag.delete(*args) } + expect(ContainerRegistry::Tag).to receive(:delete).exactly(2).times + + bulk_destroy_tags(['rc1', 'test.']) + end + end + end + + private + + def bulk_destroy_tags(names) + post :bulk_destroy, params: { + namespace_id: project.namespace, + project_id: project, + repository_id: repository, + ids: names + }, + format: :json + end + end end 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..9c7439206ef 100644 --- a/spec/javascripts/registry/components/table_registry_spec.js +++ b/spec/javascripts/registry/components/table_registry_spec.js @@ -1,63 +1,161 @@ import Vue from 'vue'; import tableRegistry from '~/registry/components/table_registry.vue'; import store from '~/registry/stores'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { repoPropsData } from '../mock_data'; -const [firstImage] = repoPropsData.list; +const [firstImage, secondImage] = repoPropsData.list; describe('table registry', () => { let vm; - let Component; + const Component = Vue.extend(tableRegistry); + const bulkDeletePath = 'path'; 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')); + const confirmationModal = (child = '') => document.querySelector(`#${vm.modalId} ${child}`); - beforeEach(() => { - Component = Vue.extend(tableRegistry); - vm = new Component({ + const createComponent = () => { + vm = mountComponentWithStore(Component, { store, - propsData: { + props: { repo: repoPropsData, }, - }).$mount(); + }); + }; + + const selectAllCheckboxes = () => vm.selectAll(); + const deselectAllCheckboxes = () => vm.deselectAll(); + + beforeEach(() => { + createComponent(); }); afterEach(() => { vm.$destroy(); }); - it('should render a table with the registry list', () => { - expect(vm.$el.querySelectorAll('table tbody tr').length).toEqual(repoPropsData.list.length); + describe('rendering', () => { + it('should render a table with the registry list', () => { + expect(vm.$el.querySelectorAll('table tbody tr').length).toEqual(repoPropsData.list.length); + }); + + it('should render registry tag', () => { + const textRendered = vm.$el + .querySelector('.table tbody tr') + .textContent.trim() + // replace additional whitespace characters (e.g. new lines) with a single empty space + .replace(/\s\s+/g, ' '); + + expect(textRendered).toContain(repoPropsData.list[0].tag); + expect(textRendered).toContain(repoPropsData.list[0].shortRevision); + expect(textRendered).toContain(repoPropsData.list[0].layers); + expect(textRendered).toContain(repoPropsData.list[0].size); + }); }); - it('should render registry tag', () => { - const textRendered = vm.$el - .querySelector('.table tbody tr') - .textContent.trim() - .replace(/\s\s+/g, ' '); + describe('multi select', () => { + it('should support multiselect and selecting a row should enable delete button', done => { + findSelectAllCheckbox().click(); + selectAllCheckboxes(); - expect(textRendered).toContain(repoPropsData.list[0].tag); - expect(textRendered).toContain(repoPropsData.list[0].shortRevision); - expect(textRendered).toContain(repoPropsData.list[0].layers); - expect(textRendered).toContain(repoPropsData.list[0].size); + 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 => { + selectAllCheckboxes(); + + 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 => { + selectAllCheckboxes(); + deselectAllCheckboxes(); + + 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 => { + selectAllCheckboxes(); + + Vue.nextTick(() => { + expect(vm.itemsToBeDeleted).toEqual([0, 1]); + expect(findDeleteBtn().disabled).toBe(false); + + findDeleteBtn().click(); + spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve()); + + Vue.nextTick(() => { + const modal = confirmationModal(); + confirmationModal('.btn-danger').click(); + + expect(modal).toExist(); + + Vue.nextTick(() => { + expect(vm.itemsToBeDeleted).toEqual([]); + expect(vm.multiDeleteItems).toHaveBeenCalledWith({ + path: bulkDeletePath, + items: [firstImage.tag, secondImage.tag], + }); + done(); + }); + }); + }); + }); }); describe('delete registry', () => { - it('should be possible to delete a registry', () => { - expect(findDeleteBtn()).toBeDefined(); + beforeEach(() => { + vm.itemsToBeDeleted = [0]; }); - it('should call deleteItem and reset itemToBeDeleted when confirming deletion', done => { - findDeleteBtn().click(); - spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve()); - + it('should be possible to delete a registry', done => { Vue.nextTick(() => { - document.querySelector(`#${vm.modalId} .btn-danger`).click(); - - expect(vm.deleteItem).toHaveBeenCalledWith(firstImage); - expect(vm.itemToBeDeleted).toBeNull(); + expect(vm.itemsToBeDeleted).toEqual([0]); + expect(findDeleteBtn()).toBeDefined(); + expect(findDeleteBtn().disabled).toBe(false); + expect(findDeleteBtnRow()).toBeDefined(); done(); }); }); + + it('should call deleteItems and reset itemsToBeDeleted when confirming deletion', done => { + Vue.nextTick(() => { + expect(vm.itemsToBeDeleted).toEqual([0]); + expect(findDeleteBtn().disabled).toBe(false); + findDeleteBtn().click(); + spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve()); + + Vue.nextTick(() => { + confirmationModal('.btn-danger').click(); + + expect(vm.itemsToBeDeleted).toEqual([]); + expect(vm.multiDeleteItems).toHaveBeenCalledWith({ + path: bulkDeletePath, + items: [firstImage.tag], + }); + done(); + }); + }); + }); }); describe('pagination', () => { @@ -65,4 +163,27 @@ describe('table registry', () => { expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); }); }); + + describe('modal content', () => { + it('should show the singular title and image name when deleting a single image', done => { + findDeleteBtnRow().click(); + + Vue.nextTick(() => { + expect(vm.modalTitle).toBe('Remove image'); + expect(vm.modalDescription).toContain(firstImage.tag); + done(); + }); + }); + + it('should show the plural title and image count when deleting more than one image', done => { + selectAllCheckboxes(); + vm.setModalDescription(); + + Vue.nextTick(() => { + expect(vm.modalTitle).toBe('Remove images'); + expect(vm.modalDescription).toContain('2 images'); + done(); + }); + }); + }); }); 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',