summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue2
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue156
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss23
-rw-r--r--locale/gitlab.pot11
-rw-r--r--spec/features/container_registry_spec.rb10
-rw-r--r--spec/javascripts/registry/components/table_registry_spec.js115
-rw-r--r--spec/javascripts/registry/mock_data.js11
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',