Commit 769e0a7d authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '334583-prevent-accidental-deletion-of-container-image-repositories' into 'master'

Prevent accidental deletion of container image repositories

See merge request gitlab-org/gitlab!66612
parents 9c1fe8d1 7f6793e0
<script> <script>
import { GlModal, GlSprintf } from '@gitlab/ui'; import { GlModal, GlSprintf, GlFormInput } from '@gitlab/ui';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import { import {
REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAG_CONFIRMATION_TEXT,
...@@ -12,6 +12,7 @@ export default { ...@@ -12,6 +12,7 @@ export default {
components: { components: {
GlModal, GlModal,
GlSprintf, GlSprintf,
GlFormInput,
}, },
props: { props: {
itemsToBeDeleted: { itemsToBeDeleted: {
...@@ -25,7 +26,15 @@ export default { ...@@ -25,7 +26,15 @@ export default {
required: false, required: false,
}, },
}, },
data() {
return {
projectPath: '',
};
},
computed: { computed: {
imageProjectPath() {
return this.itemsToBeDeleted[0]?.project?.path;
},
modalTitle() { modalTitle() {
if (this.deleteImage) { if (this.deleteImage) {
return DELETE_IMAGE_CONFIRMATION_TITLE; return DELETE_IMAGE_CONFIRMATION_TITLE;
...@@ -40,6 +49,7 @@ export default { ...@@ -40,6 +49,7 @@ export default {
if (this.deleteImage) { if (this.deleteImage) {
return { return {
message: DELETE_IMAGE_CONFIRMATION_TEXT, message: DELETE_IMAGE_CONFIRMATION_TEXT,
item: this.imageProjectPath,
}; };
} }
if (this.itemsToBeDeleted.length > 1) { if (this.itemsToBeDeleted.length > 1) {
...@@ -55,6 +65,9 @@ export default { ...@@ -55,6 +65,9 @@ export default {
item: first?.path, item: first?.path,
}; };
}, },
disablePrimaryButton() {
return this.deleteImage && this.projectPath !== this.imageProjectPath;
},
}, },
methods: { methods: {
show() { show() {
...@@ -69,10 +82,14 @@ export default { ...@@ -69,10 +82,14 @@ export default {
ref="deleteModal" ref="deleteModal"
modal-id="delete-tag-modal" modal-id="delete-tag-modal"
ok-variant="danger" ok-variant="danger"
:action-primary="{ text: __('Confirm'), attributes: { variant: 'danger' } }" :action-primary="{
text: __('Delete'),
attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }],
}"
:action-cancel="{ text: __('Cancel') }" :action-cancel="{ text: __('Cancel') }"
@primary="$emit('confirmDelete')" @primary="$emit('confirmDelete')"
@cancel="$emit('cancelDelete')" @cancel="$emit('cancelDelete')"
@change="projectPath = ''"
> >
<template #modal-title>{{ modalTitle }}</template> <template #modal-title>{{ modalTitle }}</template>
<p v-if="modalDescription" data-testid="description"> <p v-if="modalDescription" data-testid="description">
...@@ -80,7 +97,13 @@ export default { ...@@ -80,7 +97,13 @@ export default {
<template #item> <template #item>
<b>{{ modalDescription.item }}</b> <b>{{ modalDescription.item }}</b>
</template> </template>
<template #code>
<code>{{ modalDescription.item }}</code>
</template>
</gl-sprintf> </gl-sprintf>
</p> </p>
<div v-if="deleteImage">
<gl-form-input v-model="projectPath" />
</div>
</gl-modal> </gl-modal>
</template> </template>
<script> <script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { sprintf, n__, s__ } from '~/locale'; import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue';
...@@ -27,7 +27,7 @@ import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_cont ...@@ -27,7 +27,7 @@ import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_cont
export default { export default {
name: 'DetailsHeader', name: 'DetailsHeader',
components: { GlButton, GlIcon, TitleArea, MetadataItem }, components: { GlIcon, TitleArea, MetadataItem, GlDropdown, GlDropdownItem },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
...@@ -143,9 +143,22 @@ export default { ...@@ -143,9 +143,22 @@ export default {
/> />
</template> </template>
<template #right-actions> <template #right-actions>
<gl-button variant="danger" :disabled="deleteButtonDisabled" @click="$emit('delete')"> <gl-dropdown
icon="ellipsis_v"
text="More actions"
:text-sr-only="true"
category="tertiary"
no-caret
right
>
<gl-dropdown-item
variant="danger"
:disabled="deleteButtonDisabled"
@click="$emit('delete')"
>
{{ __('Delete image repository') }} {{ __('Delete image repository') }}
</gl-button> </gl-dropdown-item>
</gl-dropdown>
</template> </template>
</title-area> </title-area>
</template> </template>
...@@ -99,7 +99,7 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( ...@@ -99,7 +99,7 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__(
export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?'); export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?');
export const DELETE_IMAGE_CONFIRMATION_TEXT = s__( export const DELETE_IMAGE_CONFIRMATION_TEXT = s__(
'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone.', 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}',
); );
export const SCHEDULED_FOR_DELETION_STATUS_TITLE = s__( export const SCHEDULED_FOR_DELETION_STATUS_TITLE = s__(
......
...@@ -12,6 +12,7 @@ query getContainerRepositoryDetails($id: ID!) { ...@@ -12,6 +12,7 @@ query getContainerRepositoryDetails($id: ID!) {
expirationPolicyCleanupStatus expirationPolicyCleanupStatus
project { project {
visibility visibility
path
containerExpirationPolicy { containerExpirationPolicy {
enabled enabled
nextRunAt nextRunAt
......
...@@ -161,7 +161,7 @@ export default { ...@@ -161,7 +161,7 @@ export default {
}, },
deleteImage() { deleteImage() {
this.deleteImageAlert = true; this.deleteImageAlert = true;
this.itemsToBeDeleted = [{ path: this.containerRepository.path }]; this.itemsToBeDeleted = [{ ...this.containerRepository }];
this.$refs.deleteModal.show(); this.$refs.deleteModal.show();
}, },
deleteImageError() { deleteImageError() {
......
...@@ -8612,7 +8612,7 @@ msgstr "" ...@@ -8612,7 +8612,7 @@ 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." 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|Deletion disabled due to missing or insufficient permissions." msgid "ContainerRegistry|Deletion disabled due to missing or insufficient permissions."
......
import { GlSprintf } from '@gitlab/ui'; import { GlSprintf, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import component from '~/registry/explorer/components/details_page/delete_modal.vue'; import component from '~/registry/explorer/components/details_page/delete_modal.vue';
import { import {
REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAG_CONFIRMATION_TEXT,
...@@ -12,8 +13,9 @@ import { GlModal } from '../../stubs'; ...@@ -12,8 +13,9 @@ import { GlModal } from '../../stubs';
describe('Delete Modal', () => { describe('Delete Modal', () => {
let wrapper; let wrapper;
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findDescription = () => wrapper.find('[data-testid="description"]'); const findDescription = () => wrapper.find('[data-testid="description"]');
const findInputComponent = () => wrapper.findComponent(GlFormInput);
const mountComponent = (propsData) => { const mountComponent = (propsData) => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
...@@ -25,6 +27,13 @@ describe('Delete Modal', () => { ...@@ -25,6 +27,13 @@ describe('Delete Modal', () => {
}); });
}; };
const expectPrimaryActionStatus = (disabled = true) =>
expect(findModal().props('actionPrimary')).toMatchObject(
expect.objectContaining({
attributes: [{ variant: 'danger' }, { disabled }],
}),
);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -65,11 +74,49 @@ describe('Delete Modal', () => { ...@@ -65,11 +74,49 @@ describe('Delete Modal', () => {
it('has the correct description', () => { it('has the correct description', () => {
mountComponent({ deleteImage: true }); mountComponent({ deleteImage: true });
expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TEXT); expect(wrapper.text()).toContain(
DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(),
);
});
describe('delete button', () => {
const itemsToBeDeleted = [{ project: { path: 'foo' } }];
it('is disabled by default', () => {
mountComponent({ deleteImage: true });
expectPrimaryActionStatus();
});
it('if the user types something different from the project path is disabled', async () => {
mountComponent({ deleteImage: true, itemsToBeDeleted });
findInputComponent().vm.$emit('input', 'bar');
await nextTick();
expectPrimaryActionStatus();
});
it('if the user types the project path it is enabled', async () => {
mountComponent({ deleteImage: true, itemsToBeDeleted });
findInputComponent().vm.$emit('input', 'foo');
await nextTick();
expectPrimaryActionStatus(false);
});
}); });
}); });
describe('when we are deleting tags', () => { describe('when we are deleting tags', () => {
it('delete button is enabled', () => {
mountComponent();
expectPrimaryActionStatus(false);
});
describe('itemsToBeDeleted contains one element', () => { describe('itemsToBeDeleted contains one element', () => {
beforeEach(() => { beforeEach(() => {
mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] });
......
import { GlButton, GlIcon } from '@gitlab/ui'; import { GlDropdownItem, GlIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { GlDropdown } from 'jest/registry/explorer/stubs';
import component from '~/registry/explorer/components/details_page/details_header.vue'; import component from '~/registry/explorer/components/details_page/details_header.vue';
import { import {
UNSCHEDULED_STATUS, UNSCHEDULED_STATUS,
...@@ -48,8 +49,8 @@ describe('Details Header', () => { ...@@ -48,8 +49,8 @@ describe('Details Header', () => {
const findTitle = () => findByTestId('title'); const findTitle = () => findByTestId('title');
const findTagsCount = () => findByTestId('tags-count'); const findTagsCount = () => findByTestId('tags-count');
const findCleanup = () => findByTestId('cleanup'); const findCleanup = () => findByTestId('cleanup');
const findDeleteButton = () => wrapper.find(GlButton); const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
const findInfoIcon = () => wrapper.find(GlIcon); const findInfoIcon = () => wrapper.findComponent(GlIcon);
const waitForMetadataItems = async () => { const waitForMetadataItems = async () => {
// Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available
...@@ -84,6 +85,8 @@ describe('Details Header', () => { ...@@ -84,6 +85,8 @@ describe('Details Header', () => {
mocks, mocks,
stubs: { stubs: {
TitleArea, TitleArea,
GlDropdown,
GlDropdownItem,
}, },
}); });
}; };
...@@ -152,10 +155,11 @@ describe('Details Header', () => { ...@@ -152,10 +155,11 @@ describe('Details Header', () => {
it('has the correct props', () => { it('has the correct props', () => {
mountComponent(); mountComponent();
expect(findDeleteButton().props()).toMatchObject({ expect(findDeleteButton().attributes()).toMatchObject(
expect.objectContaining({
variant: 'danger', variant: 'danger',
disabled: false, }),
}); );
}); });
it('emits the correct event', () => { it('emits the correct event', () => {
...@@ -168,16 +172,16 @@ describe('Details Header', () => { ...@@ -168,16 +172,16 @@ describe('Details Header', () => {
it.each` it.each`
canDelete | disabled | isDisabled canDelete | disabled | isDisabled
${true} | ${false} | ${false} ${true} | ${false} | ${undefined}
${true} | ${true} | ${true} ${true} | ${true} | ${'true'}
${false} | ${false} | ${true} ${false} | ${false} | ${'true'}
${false} | ${true} | ${true} ${false} | ${true} | ${'true'}
`( `(
'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled',
({ canDelete, disabled, isDisabled }) => { ({ canDelete, disabled, isDisabled }) => {
mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } });
expect(findDeleteButton().props('disabled')).toBe(isDisabled); expect(findDeleteButton().attributes('disabled')).toBe(isDisabled);
}, },
); );
}); });
......
...@@ -119,6 +119,7 @@ export const containerRepositoryMock = { ...@@ -119,6 +119,7 @@ export const containerRepositoryMock = {
expirationPolicyCleanupStatus: 'UNSCHEDULED', expirationPolicyCleanupStatus: 'UNSCHEDULED',
project: { project: {
visibility: 'public', visibility: 'public',
path: 'gitlab-test',
containerExpirationPolicy: { containerExpirationPolicy: {
enabled: false, enabled: false,
nextRunAt: '2020-11-27T08:59:27Z', nextRunAt: '2020-11-27T08:59:27Z',
......
...@@ -2,6 +2,7 @@ import { ...@@ -2,6 +2,7 @@ import {
GlModal as RealGlModal, GlModal as RealGlModal,
GlEmptyState as RealGlEmptyState, GlEmptyState as RealGlEmptyState,
GlSkeletonLoader as RealGlSkeletonLoader, GlSkeletonLoader as RealGlSkeletonLoader,
GlDropdown as RealGlDropdown,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { RouterLinkStub } from '@vue/test-utils'; import { RouterLinkStub } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
...@@ -38,3 +39,7 @@ export const ListItem = { ...@@ -38,3 +39,7 @@ export const ListItem = {
}; };
}, },
}; };
export const GlDropdown = stubComponent(RealGlDropdown, {
template: '<div><slot></slot></div>',
});
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