Commit 5988f878 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '336933-improve-the-process-of-deleting-image-tags' into 'master'

Container Details: move delete to action menu

See merge request gitlab-org/gitlab!71541
parents dd4410f0 16718afb
<script> <script>
import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; import {
GlFormCheckbox,
GlTooltipDirective,
GlSprintf,
GlIcon,
GlDropdown,
GlDropdownItem,
} from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
...@@ -11,22 +18,22 @@ import { ...@@ -11,22 +18,22 @@ import {
REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_TITLE,
DIGEST_LABEL, DIGEST_LABEL,
CREATED_AT_LABEL, CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT, PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST, MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST, CONFIGURATION_DETAILS_ROW_TEST,
MISSING_MANIFEST_WARNING_TOOLTIP, MISSING_MANIFEST_WARNING_TOOLTIP,
NOT_AVAILABLE_TEXT, NOT_AVAILABLE_TEXT,
NOT_AVAILABLE_SIZE, NOT_AVAILABLE_SIZE,
MORE_ACTIONS_TEXT,
} from '../../constants/index'; } from '../../constants/index';
import DeleteButton from '../delete_button.vue';
export default { export default {
components: { components: {
GlSprintf, GlSprintf,
GlFormCheckbox, GlFormCheckbox,
GlIcon, GlIcon,
DeleteButton, GlDropdown,
GlDropdownItem,
ListItem, ListItem,
ClipboardButton, ClipboardButton,
TimeAgoTooltip, TimeAgoTooltip,
...@@ -60,11 +67,11 @@ export default { ...@@ -60,11 +67,11 @@ export default {
REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_TITLE,
DIGEST_LABEL, DIGEST_LABEL,
CREATED_AT_LABEL, CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT, PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST, MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST, CONFIGURATION_DETAILS_ROW_TEST,
MISSING_MANIFEST_WARNING_TOOLTIP, MISSING_MANIFEST_WARNING_TOOLTIP,
MORE_ACTIONS_TEXT,
}, },
computed: { computed: {
formattedSize() { formattedSize() {
...@@ -173,15 +180,26 @@ export default { ...@@ -173,15 +180,26 @@ export default {
</span> </span>
</template> </template>
<template #right-action> <template #right-action>
<delete-button <gl-dropdown
:disabled="isDeleteDisabled" v-if="!isDeleteDisabled"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" icon="ellipsis_v"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" :text="$options.i18n.MORE_ACTIONS_TEXT"
:tooltip-disabled="tag.canDelete" :text-sr-only="true"
data-qa-selector="tag_delete_button" category="tertiary"
data-testid="single-delete-button" no-caret
@delete="$emit('delete')" right
/> data-testid="additional-actions"
data-qa-selector="more_actions_menu"
>
<gl-dropdown-item
variant="danger"
data-testid="single-delete-button"
data-qa-selector="tag_delete_button"
@click="$emit('delete')"
>
{{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }}
</gl-dropdown-item>
</gl-dropdown>
</template> </template>
<template v-if="!isInvalidTag" #details-published> <template v-if="!isInvalidTag" #details-published>
......
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image'); export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
export const MORE_ACTIONS_TEXT = __('More actions');
...@@ -30,7 +30,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__( ...@@ -30,7 +30,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__(
'ContainerRegistry|Configuration digest: %{digest}', 'ContainerRegistry|Configuration digest: %{digest}',
); );
export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Delete tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags'); export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__( export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
...@@ -61,10 +61,6 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__( ...@@ -61,10 +61,6 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
); );
export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__(
'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.',
);
export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'ContainerRegistry|Invalid tag: missing manifest digest', 'ContainerRegistry|Invalid tag: missing manifest digest',
); );
......
...@@ -8896,10 +8896,10 @@ msgstr "" ...@@ -8896,10 +8896,10 @@ msgstr ""
msgid "ContainerRegistry|Delete selected tags" msgid "ContainerRegistry|Delete selected tags"
msgstr "" msgstr ""
msgid "ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}" msgid "ContainerRegistry|Delete tag"
msgstr "" msgstr ""
msgid "ContainerRegistry|Deletion disabled due to missing or insufficient permissions." msgid "ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}"
msgstr "" msgstr ""
msgid "ContainerRegistry|Digest: %{imageId}" msgid "ContainerRegistry|Digest: %{imageId}"
......
...@@ -9,6 +9,10 @@ module QA ...@@ -9,6 +9,10 @@ module QA
element :registry_image_content element :registry_image_content
end end
view 'app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue' do
element :more_actions_menu
end
view 'app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue' do view 'app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue' do
element :tag_delete_button element :tag_delete_button
end end
...@@ -30,6 +34,7 @@ module QA ...@@ -30,6 +34,7 @@ module QA
end end
def click_delete def click_delete
click_element(:more_actions_menu)
click_element(:tag_delete_button) click_element(:tag_delete_button)
find_button('Delete').click find_button('Delete').click
end end
......
...@@ -81,6 +81,7 @@ RSpec.describe 'Container Registry', :js do ...@@ -81,6 +81,7 @@ RSpec.describe 'Container Registry', :js do
expect(service).to receive(:execute).with(container_repository) { { status: :success } } expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service }
first('[data-testid="additional-actions"]').click
first('[data-testid="single-delete-button"]').click first('[data-testid="single-delete-button"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag') expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click find('.modal .modal-footer .btn-danger').click
......
...@@ -96,6 +96,7 @@ RSpec.describe 'Container Registry', :js do ...@@ -96,6 +96,7 @@ RSpec.describe 'Container Registry', :js do
expect(service).to receive(:execute).with(container_repository) { { status: :success } } expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service }
first('[data-testid="additional-actions"]').click
first('[data-testid="single-delete-button"]').click first('[data-testid="single-delete-button"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag') expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click find('.modal .modal-footer .btn-danger').click
......
import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui'; import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import DeleteButton from '~/registry/explorer/components/delete_button.vue';
import component from '~/registry/explorer/components/details_page/tags_list_row.vue'; import component from '~/registry/explorer/components/details_page/tags_list_row.vue';
import { import {
REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_TITLE,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
MISSING_MANIFEST_WARNING_TOOLTIP, MISSING_MANIFEST_WARNING_TOOLTIP,
NOT_AVAILABLE_TEXT, NOT_AVAILABLE_TEXT,
NOT_AVAILABLE_SIZE, NOT_AVAILABLE_SIZE,
...@@ -25,19 +24,20 @@ describe('tags list row', () => { ...@@ -25,19 +24,20 @@ describe('tags list row', () => {
const defaultProps = { tag, isMobile: false, index: 0 }; const defaultProps = { tag, isMobile: false, index: 0 };
const findCheckbox = () => wrapper.find(GlFormCheckbox); const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findName = () => wrapper.find('[data-testid="name"]'); const findName = () => wrapper.find('[data-testid="name"]');
const findSize = () => wrapper.find('[data-testid="size"]'); const findSize = () => wrapper.find('[data-testid="size"]');
const findTime = () => wrapper.find('[data-testid="time"]'); const findTime = () => wrapper.find('[data-testid="time"]');
const findShortRevision = () => wrapper.find('[data-testid="digest"]'); const findShortRevision = () => wrapper.find('[data-testid="digest"]');
const findClipboardButton = () => wrapper.find(ClipboardButton); const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const findDeleteButton = () => wrapper.find(DeleteButton); const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip);
const findDetailsRows = () => wrapper.findAll(DetailsRow); const findDetailsRows = () => wrapper.findAll(DetailsRow);
const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]');
const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]');
const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]');
const findWarningIcon = () => wrapper.find(GlIcon); const findWarningIcon = () => wrapper.findComponent(GlIcon);
const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown);
const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
const mountComponent = (propsData = defaultProps) => { const mountComponent = (propsData = defaultProps) => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
...@@ -45,6 +45,7 @@ describe('tags list row', () => { ...@@ -45,6 +45,7 @@ describe('tags list row', () => {
GlSprintf, GlSprintf,
ListItem, ListItem,
DetailsRow, DetailsRow,
GlDropdown,
}, },
propsData, propsData,
directives: { directives: {
...@@ -262,44 +263,59 @@ describe('tags list row', () => { ...@@ -262,44 +263,59 @@ describe('tags list row', () => {
}); });
}); });
describe('delete button', () => { describe('additional actions menu', () => {
it('exists', () => { it('exists', () => {
mountComponent(); mountComponent();
expect(findDeleteButton().exists()).toBe(true); expect(findAdditionalActionsMenu().exists()).toBe(true);
}); });
it('has the correct props/attributes', () => { it('has the correct props', () => {
mountComponent(); mountComponent();
expect(findDeleteButton().attributes()).toMatchObject({ expect(findAdditionalActionsMenu().props()).toMatchObject({
title: REMOVE_TAG_BUTTON_TITLE, icon: 'ellipsis_v',
tooltiptitle: REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, text: 'More actions',
tooltipdisabled: 'true', textSrOnly: true,
category: 'tertiary',
right: true,
}); });
}); });
it.each` it.each`
canDelete | digest | disabled canDelete | digest | disabled | visible
${true} | ${null} | ${true} ${true} | ${null} | ${true} | ${false}
${false} | ${'foo'} | ${true} ${false} | ${'foo'} | ${true} | ${false}
${false} | ${null} | ${true} ${false} | ${null} | ${true} | ${false}
${true} | ${'foo'} | ${true} ${true} | ${'foo'} | ${true} | ${false}
${true} | ${'foo'} | ${false} | ${true}
`( `(
'is disabled when canDelete is $canDelete and digest is $digest and disabled is $disabled', 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled',
({ canDelete, digest, disabled }) => { ({ canDelete, digest, disabled, visible }) => {
mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled }); mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled });
expect(findDeleteButton().attributes('disabled')).toBe('true'); expect(findAdditionalActionsMenu().exists()).toBe(visible);
}, },
); );
it('delete event emits delete', () => { describe('delete button', () => {
mountComponent(); it('exists and has the correct attrs', () => {
mountComponent();
expect(findDeleteButton().exists()).toBe(true);
expect(findDeleteButton().attributes()).toMatchObject({
variant: 'danger',
});
expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE);
});
findDeleteButton().vm.$emit('delete'); it('delete event emits delete', () => {
mountComponent();
expect(wrapper.emitted('delete')).toEqual([[]]); findDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[]]);
});
}); });
}); });
......
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