Commit 7f6793e0 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Natalia Tepluhina

Prevent accidental deletion of container image repositories

parent 4a7db8d3
<script>
import { GlModal, GlSprintf } from '@gitlab/ui';
import { GlModal, GlSprintf, GlFormInput } from '@gitlab/ui';
import { n__ } from '~/locale';
import {
REMOVE_TAG_CONFIRMATION_TEXT,
......@@ -12,6 +12,7 @@ export default {
components: {
GlModal,
GlSprintf,
GlFormInput,
},
props: {
itemsToBeDeleted: {
......@@ -25,7 +26,15 @@ export default {
required: false,
},
},
data() {
return {
projectPath: '',
};
},
computed: {
imageProjectPath() {
return this.itemsToBeDeleted[0]?.project?.path;
},
modalTitle() {
if (this.deleteImage) {
return DELETE_IMAGE_CONFIRMATION_TITLE;
......@@ -40,6 +49,7 @@ export default {
if (this.deleteImage) {
return {
message: DELETE_IMAGE_CONFIRMATION_TEXT,
item: this.imageProjectPath,
};
}
if (this.itemsToBeDeleted.length > 1) {
......@@ -55,6 +65,9 @@ export default {
item: first?.path,
};
},
disablePrimaryButton() {
return this.deleteImage && this.projectPath !== this.imageProjectPath;
},
},
methods: {
show() {
......@@ -69,10 +82,14 @@ export default {
ref="deleteModal"
modal-id="delete-tag-modal"
ok-variant="danger"
:action-primary="{ text: __('Confirm'), attributes: { variant: 'danger' } }"
:action-primary="{
text: __('Delete'),
attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }],
}"
:action-cancel="{ text: __('Cancel') }"
@primary="$emit('confirmDelete')"
@cancel="$emit('cancelDelete')"
@change="projectPath = ''"
>
<template #modal-title>{{ modalTitle }}</template>
<p v-if="modalDescription" data-testid="description">
......@@ -80,7 +97,13 @@ export default {
<template #item>
<b>{{ modalDescription.item }}</b>
</template>
<template #code>
<code>{{ modalDescription.item }}</code>
</template>
</gl-sprintf>
</p>
<div v-if="deleteImage">
<gl-form-input v-model="projectPath" />
</div>
</gl-modal>
</template>
<script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
......@@ -27,7 +27,7 @@ import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_cont
export default {
name: 'DetailsHeader',
components: { GlButton, GlIcon, TitleArea, MetadataItem },
components: { GlIcon, TitleArea, MetadataItem, GlDropdown, GlDropdownItem },
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -143,9 +143,22 @@ export default {
/>
</template>
<template #right-actions>
<gl-button variant="danger" :disabled="deleteButtonDisabled" @click="$emit('delete')">
{{ __('Delete image repository') }}
</gl-button>
<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') }}
</gl-dropdown-item>
</gl-dropdown>
</template>
</title-area>
</template>
......@@ -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_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__(
......
......@@ -12,6 +12,7 @@ query getContainerRepositoryDetails($id: ID!) {
expirationPolicyCleanupStatus
project {
visibility
path
containerExpirationPolicy {
enabled
nextRunAt
......
......@@ -161,7 +161,7 @@ export default {
},
deleteImage() {
this.deleteImageAlert = true;
this.itemsToBeDeleted = [{ path: this.containerRepository.path }];
this.itemsToBeDeleted = [{ ...this.containerRepository }];
this.$refs.deleteModal.show();
},
deleteImageError() {
......
......@@ -8608,7 +8608,7 @@ msgstr ""
msgid "ContainerRegistry|Delete selected tags"
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 ""
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 { nextTick } from 'vue';
import component from '~/registry/explorer/components/details_page/delete_modal.vue';
import {
REMOVE_TAG_CONFIRMATION_TEXT,
......@@ -12,8 +13,9 @@ import { GlModal } from '../../stubs';
describe('Delete Modal', () => {
let wrapper;
const findModal = () => wrapper.find(GlModal);
const findModal = () => wrapper.findComponent(GlModal);
const findDescription = () => wrapper.find('[data-testid="description"]');
const findInputComponent = () => wrapper.findComponent(GlFormInput);
const mountComponent = (propsData) => {
wrapper = shallowMount(component, {
......@@ -25,6 +27,13 @@ describe('Delete Modal', () => {
});
};
const expectPrimaryActionStatus = (disabled = true) =>
expect(findModal().props('actionPrimary')).toMatchObject(
expect.objectContaining({
attributes: [{ variant: 'danger' }, { disabled }],
}),
);
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -65,11 +74,49 @@ describe('Delete Modal', () => {
it('has the correct description', () => {
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', () => {
it('delete button is enabled', () => {
mountComponent();
expectPrimaryActionStatus(false);
});
describe('itemsToBeDeleted contains one element', () => {
beforeEach(() => {
mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] });
......
import { GlButton, GlIcon } from '@gitlab/ui';
import { GlDropdownItem, GlIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
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 {
UNSCHEDULED_STATUS,
......@@ -48,8 +49,8 @@ describe('Details Header', () => {
const findTitle = () => findByTestId('title');
const findTagsCount = () => findByTestId('tags-count');
const findCleanup = () => findByTestId('cleanup');
const findDeleteButton = () => wrapper.find(GlButton);
const findInfoIcon = () => wrapper.find(GlIcon);
const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
const findInfoIcon = () => wrapper.findComponent(GlIcon);
const waitForMetadataItems = async () => {
// 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', () => {
mocks,
stubs: {
TitleArea,
GlDropdown,
GlDropdownItem,
},
});
};
......@@ -152,10 +155,11 @@ describe('Details Header', () => {
it('has the correct props', () => {
mountComponent();
expect(findDeleteButton().props()).toMatchObject({
variant: 'danger',
disabled: false,
});
expect(findDeleteButton().attributes()).toMatchObject(
expect.objectContaining({
variant: 'danger',
}),
);
});
it('emits the correct event', () => {
......@@ -168,16 +172,16 @@ describe('Details Header', () => {
it.each`
canDelete | disabled | isDisabled
${true} | ${false} | ${false}
${true} | ${true} | ${true}
${false} | ${false} | ${true}
${false} | ${true} | ${true}
${true} | ${false} | ${undefined}
${true} | ${true} | ${'true'}
${false} | ${false} | ${'true'}
${false} | ${true} | ${'true'}
`(
'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled',
({ canDelete, disabled, isDisabled }) => {
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 = {
expirationPolicyCleanupStatus: 'UNSCHEDULED',
project: {
visibility: 'public',
path: 'gitlab-test',
containerExpirationPolicy: {
enabled: false,
nextRunAt: '2020-11-27T08:59:27Z',
......
......@@ -2,6 +2,7 @@ import {
GlModal as RealGlModal,
GlEmptyState as RealGlEmptyState,
GlSkeletonLoader as RealGlSkeletonLoader,
GlDropdown as RealGlDropdown,
} from '@gitlab/ui';
import { RouterLinkStub } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
......@@ -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