Commit 116f3515 authored by Scott Hampton's avatar Scott Hampton

Merge branch '216761-refactor-delete-to-own-component' into 'master'

Prepare to add delete image to container registry details

See merge request gitlab-org/gitlab!52320
parents 53c318c2 d19f0b9b
<script>
import { produce } from 'immer';
import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '../constants/index';
export default {
props: {
id: {
type: String,
required: false,
default: null,
},
useUpdateFn: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
updateImageStatus(store, { data: { destroyContainerRepository } }) {
const variables = {
id: this.id,
first: GRAPHQL_PAGE_SIZE,
};
const sourceData = store.readQuery({
query: getContainerRepositoryDetailsQuery,
variables,
});
const data = produce(sourceData, (draftState) => {
// eslint-disable-next-line no-param-reassign
draftState.containerRepository.status =
destroyContainerRepository.containerRepository.status;
});
store.writeQuery({
query: getContainerRepositoryDetailsQuery,
variables,
data,
});
},
doDelete() {
this.$emit('start');
return this.$apollo
.mutate({
mutation: deleteContainerRepositoryMutation,
variables: {
id: this.id,
},
update: this.useUpdateFn ? this.updateImageStatus : undefined,
})
.then(({ data }) => {
if (data?.destroyContainerRepository?.errors[0]) {
this.$emit('error', data?.destroyContainerRepository?.errors);
return;
}
this.$emit('success');
})
.catch((e) => {
// note: we are adding an array to follow the same format of the error raised above
this.$emit('error', [e]);
})
.finally(() => {
this.$emit('end');
});
},
},
render() {
if (this.$scopedSlots?.default) {
return this.$scopedSlots.default({ doDelete: this.doDelete });
}
return null;
},
};
</script>
import { s__, __ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
// Translations strings
export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
......@@ -32,6 +33,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__(
export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
`ContainerRegistry|You are about to remove %{item}. Are you sure?`,
);
......@@ -76,6 +78,29 @@ export const CLEANUP_DISABLED_TOOLTIP = s__(
'ContainerRegistry|Cleanup is disabled for this project',
);
export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while scheduling the image for deletion.',
);
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.',
);
export const SCHEDULED_FOR_DELETION_STATUS_TITLE = s__(
'ContainerRegistry|Image repository will be deleted',
);
export const SCHEDULED_FOR_DELETION_STATUS_MESSAGE = s__(
'ContainerRegistry|This image repository will be deleted. %{linkStart}Learn more.%{linkEnd}',
);
export const FAILED_DELETION_STATUS_TITLE = s__(
'ContainerRegistry|Image repository deletion failed',
);
export const FAILED_DELETION_STATUS_MESSAGE = s__(
'ContainerRegistry|This image repository has failed to be deleted',
);
// Parameters
export const DEFAULT_PAGE = 1;
......@@ -85,15 +110,39 @@ export const ALERT_SUCCESS_TAG = 'success_tag';
export const ALERT_DANGER_TAG = 'danger_tag';
export const ALERT_SUCCESS_TAGS = 'success_tags';
export const ALERT_DANGER_TAGS = 'danger_tags';
export const ALERT_DANGER_IMAGE = 'danger_image';
export const DELETE_SCHEDULED = 'DELETE_SCHEDULED';
export const DELETE_FAILED = 'DELETE_FAILED';
export const ALERT_MESSAGES = {
[ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE,
[ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE,
[ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE,
[ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE,
[ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE,
};
export const UNFINISHED_STATUS = 'UNFINISHED';
export const UNSCHEDULED_STATUS = 'UNSCHEDULED';
export const SCHEDULED_STATUS = 'SCHEDULED';
export const ONGOING_STATUS = 'ONGOING';
export const IMAGE_STATUS_TITLES = {
[DELETE_SCHEDULED]: SCHEDULED_FOR_DELETION_STATUS_TITLE,
[DELETE_FAILED]: FAILED_DELETION_STATUS_TITLE,
};
export const IMAGE_STATUS_MESSAGES = {
[DELETE_SCHEDULED]: SCHEDULED_FOR_DELETION_STATUS_MESSAGE,
[DELETE_FAILED]: FAILED_DELETION_STATUS_MESSAGE,
};
export const IMAGE_STATUS_ALERT_TYPE = {
[DELETE_SCHEDULED]: 'info',
[DELETE_FAILED]: 'warning',
};
export const PACKAGE_DELETE_HELP_PAGE_PATH = helpPagePath('user/packages/container_registry', {
anchor: 'delete-images',
});
......@@ -14,9 +14,9 @@ import getContainerRepositoriesQuery from 'shared_queries/container_registry/get
import Tracking from '~/tracking';
import createFlash from '~/flash';
import RegistryHeader from '../components/list_page/registry_header.vue';
import DeleteImage from '../components/delete_image.vue';
import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
......@@ -60,6 +60,7 @@ export default {
GlSkeletonLoader,
GlSearchBoxByClick,
RegistryHeader,
DeleteImage,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -179,30 +180,6 @@ export default {
this.itemToDelete = item;
this.$refs.deleteModal.show();
},
handleDeleteImage() {
this.track('confirm_delete');
this.mutationLoading = true;
return this.$apollo
.mutate({
mutation: deleteContainerRepositoryMutation,
variables: {
id: this.itemToDelete.id,
},
})
.then(({ data }) => {
if (data?.destroyContainerRepository?.errors[0]) {
this.deleteAlertType = 'danger';
} else {
this.deleteAlertType = 'success';
}
})
.catch(() => {
this.deleteAlertType = 'danger';
})
.finally(() => {
this.mutationLoading = false;
});
},
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
......@@ -250,6 +227,10 @@ export default {
});
}
},
startDelete() {
this.track('confirm_delete');
this.mutationLoading = true;
},
},
};
</script>
......@@ -358,23 +339,32 @@ export default {
</template>
</template>
<gl-modal
ref="deleteModal"
modal-id="delete-image-modal"
ok-variant="danger"
@ok="handleDeleteImage"
@cancel="track('cancel_delete')"
<delete-image
:id="itemToDelete.id"
@start="startDelete"
@error="deleteAlertType = 'danger'"
@success="deleteAlertType = 'success'"
@end="mutationLoading = false"
>
<template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
<p>
<gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
<template #title>
<b>{{ itemToDelete.path }}</b>
</template>
</gl-sprintf>
</p>
<template #modal-ok>{{ __('Remove') }}</template>
</gl-modal>
<template #default="{ doDelete }">
<gl-modal
ref="deleteModal"
modal-id="delete-image-modal"
:action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }"
@primary="doDelete"
@cancel="track('cancel_delete')"
>
<template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
<p>
<gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
<template #title>
<b>{{ itemToDelete.path }}</b>
</template>
</gl-sprintf>
</p>
</gl-modal>
</template>
</delete-image>
</template>
</div>
</template>
......@@ -7585,9 +7585,15 @@ msgstr ""
msgid "ContainerRegistry|Copy push command"
msgstr ""
msgid "ContainerRegistry|Delete image repository?"
msgstr ""
msgid "ContainerRegistry|Delete selected"
msgstr ""
msgid "ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone."
msgstr ""
msgid "ContainerRegistry|Deletion disabled due to missing or insufficient permissions."
msgstr ""
......@@ -7615,6 +7621,12 @@ msgstr ""
msgid "ContainerRegistry|Image Repositories"
msgstr ""
msgid "ContainerRegistry|Image repository deletion failed"
msgstr ""
msgid "ContainerRegistry|Image repository will be deleted"
msgstr ""
msgid "ContainerRegistry|Image tags"
msgstr ""
......@@ -7707,6 +7719,9 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again."
msgstr ""
msgid "ContainerRegistry|Something went wrong while scheduling the image for deletion."
msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the cleanup policy."
msgstr ""
......@@ -7752,9 +7767,15 @@ msgstr ""
msgid "ContainerRegistry|This image has no active tags"
msgstr ""
msgid "ContainerRegistry|This image repository has failed to be deleted"
msgstr ""
msgid "ContainerRegistry|This image repository is scheduled for deletion"
msgstr ""
msgid "ContainerRegistry|This image repository will be deleted. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "ContainerRegistry|This project's cleanup policy for tags is not enabled."
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import component from '~/registry/explorer/components/delete_image.vue';
import deleteContainerRepositoryMutation from '~/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql';
import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '~/registry/explorer/constants/index';
describe('Delete Image', () => {
let wrapper;
const id = '1';
const storeMock = {
readQuery: jest.fn().mockReturnValue({
containerRepository: {
status: 'foo',
},
}),
writeQuery: jest.fn(),
};
const updatePayload = {
data: {
destroyContainerRepository: {
containerRepository: {
status: 'baz',
},
},
},
};
const findButton = () => wrapper.find('button');
const mountComponent = ({
propsData = { id },
mutate = jest.fn().mockResolvedValue({}),
} = {}) => {
wrapper = shallowMount(component, {
propsData,
mocks: {
$apollo: {
mutate,
},
},
scopedSlots: {
default: '<button @click="props.doDelete">test</button>',
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('executes apollo mutate on doDelete', () => {
const mutate = jest.fn().mockResolvedValue({});
mountComponent({ mutate });
wrapper.vm.doDelete();
expect(mutate).toHaveBeenCalledWith({
mutation: deleteContainerRepositoryMutation,
variables: {
id,
},
update: undefined,
});
});
it('on success emits the correct events', async () => {
const mutate = jest.fn().mockResolvedValue({});
mountComponent({ mutate });
wrapper.vm.doDelete();
await waitForPromises();
expect(wrapper.emitted('start')).toEqual([[]]);
expect(wrapper.emitted('success')).toEqual([[]]);
expect(wrapper.emitted('end')).toEqual([[]]);
});
it('when a payload contains an error emits an error event', async () => {
const mutate = jest
.fn()
.mockResolvedValue({ data: { destroyContainerRepository: { errors: ['foo'] } } });
mountComponent({ mutate });
wrapper.vm.doDelete();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[['foo']]]);
});
it('when the api call errors emits an error event', async () => {
const mutate = jest.fn().mockRejectedValue('error');
mountComponent({ mutate });
wrapper.vm.doDelete();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[['error']]]);
});
it('uses the update function, when the prop is set to true', () => {
const mutate = jest.fn().mockResolvedValue({});
mountComponent({ mutate, propsData: { id, useUpdateFn: true } });
wrapper.vm.doDelete();
expect(mutate).toHaveBeenCalledWith({
mutation: deleteContainerRepositoryMutation,
variables: {
id,
},
update: wrapper.vm.updateImageStatus,
});
});
it('updateImage status reads and write to the cache', () => {
mountComponent();
const variables = {
id,
first: GRAPHQL_PAGE_SIZE,
};
wrapper.vm.updateImageStatus(storeMock, updatePayload);
expect(storeMock.readQuery).toHaveBeenCalledWith({
query: getContainerRepositoryDetailsQuery,
variables,
});
expect(storeMock.writeQuery).toHaveBeenCalledWith({
query: getContainerRepositoryDetailsQuery,
variables,
data: {
containerRepository: {
status: updatePayload.data.destroyContainerRepository.containerRepository.status,
},
},
});
});
it('binds the doDelete function to the default scoped slot', () => {
const mutate = jest.fn().mockResolvedValue({});
mountComponent({ mutate });
findButton().trigger('click');
expect(mutate).toHaveBeenCalled();
});
});
......@@ -11,6 +11,7 @@ import GroupEmptyState from '~/registry/explorer/components/list_page/group_empt
import ProjectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue';
import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue';
import ImageList from '~/registry/explorer/components/list_page/image_list.vue';
import DeleteImage from '~/registry/explorer/components/delete_image.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import {
......@@ -27,7 +28,6 @@ import {
graphQLImageListMock,
graphQLImageDeleteMock,
deletedContainerRepository,
graphQLImageDeleteMockError,
graphQLEmptyImageListMock,
graphQLEmptyGroupImageListMock,
pageInfo,
......@@ -58,6 +58,7 @@ describe('List Page', () => {
const findListHeader = () => wrapper.find('[data-testid="listHeader"]');
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const findDeleteImage = () => wrapper.find(DeleteImage);
const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers();
......@@ -91,6 +92,7 @@ describe('List Page', () => {
GlSprintf,
RegistryHeader,
TitleArea,
DeleteImage,
},
mocks: {
$toast,
......@@ -300,23 +302,22 @@ describe('List Page', () => {
});
describe('delete image', () => {
const deleteImage = async () => {
await wrapper.vm.$nextTick();
const selectImageForDeletion = async () => {
await waitForApolloRequestRender();
findImageList().vm.$emit('delete', deletedContainerRepository);
findDeleteModal().vm.$emit('ok');
await waitForApolloRequestRender();
};
it('should call deleteItem when confirming deletion', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
mountComponent({ mutationResolver });
await deleteImage();
await selectImageForDeletion();
findDeleteModal().vm.$emit('primary');
await waitForApolloRequestRender();
expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository);
expect(mutationResolver).toHaveBeenCalledWith({ id: deletedContainerRepository.id });
const updatedImage = findImageList()
.props('images')
......@@ -326,10 +327,12 @@ describe('List Page', () => {
});
it('should show a success alert when delete request is successful', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
mountComponent({ mutationResolver });
mountComponent();
await deleteImage();
await selectImageForDeletion();
findDeleteImage().vm.$emit('success');
await wrapper.vm.$nextTick();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
......@@ -340,23 +343,12 @@ describe('List Page', () => {
describe('when delete request fails it shows an alert', () => {
it('user recoverable error', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMockError);
mountComponent({ mutationResolver });
mountComponent();
await deleteImage();
await selectImageForDeletion();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
it('network error', async () => {
const mutationResolver = jest.fn().mockRejectedValue();
mountComponent({ mutationResolver });
await deleteImage();
findDeleteImage().vm.$emit('error');
await wrapper.vm.$nextTick();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
......@@ -499,9 +491,8 @@ describe('List Page', () => {
testTrackingCall('cancel_delete');
});
it('send an event when confirm is clicked on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
it('send an event when the deletion starts', () => {
findDeleteImage().vm.$emit('start');
testTrackingCall('confirm_delete');
});
});
......
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