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 { s__, __ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
// Translations strings // Translations strings
export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
...@@ -32,6 +33,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__( ...@@ -32,6 +33,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__(
export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected'); export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__( export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
`ContainerRegistry|You are about to remove %{item}. Are you sure?`, `ContainerRegistry|You are about to remove %{item}. Are you sure?`,
); );
...@@ -76,6 +78,29 @@ export const CLEANUP_DISABLED_TOOLTIP = s__( ...@@ -76,6 +78,29 @@ export const CLEANUP_DISABLED_TOOLTIP = s__(
'ContainerRegistry|Cleanup is disabled for this project', '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 // Parameters
export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE = 1;
...@@ -85,15 +110,39 @@ export const ALERT_SUCCESS_TAG = 'success_tag'; ...@@ -85,15 +110,39 @@ export const ALERT_SUCCESS_TAG = 'success_tag';
export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_DANGER_TAG = 'danger_tag';
export const ALERT_SUCCESS_TAGS = 'success_tags'; export const ALERT_SUCCESS_TAGS = 'success_tags';
export const ALERT_DANGER_TAGS = 'danger_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 = { export const ALERT_MESSAGES = {
[ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE, [ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE,
[ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE, [ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE,
[ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE, [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE,
[ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE, [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE,
[ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE,
}; };
export const UNFINISHED_STATUS = 'UNFINISHED'; export const UNFINISHED_STATUS = 'UNFINISHED';
export const UNSCHEDULED_STATUS = 'UNSCHEDULED'; export const UNSCHEDULED_STATUS = 'UNSCHEDULED';
export const SCHEDULED_STATUS = 'SCHEDULED'; export const SCHEDULED_STATUS = 'SCHEDULED';
export const ONGOING_STATUS = 'ONGOING'; 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 ...@@ -14,9 +14,9 @@ import getContainerRepositoriesQuery from 'shared_queries/container_registry/get
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import createFlash from '~/flash'; import createFlash from '~/flash';
import RegistryHeader from '../components/list_page/registry_header.vue'; 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 getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql';
import { import {
DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_SUCCESS_MESSAGE,
...@@ -60,6 +60,7 @@ export default { ...@@ -60,6 +60,7 @@ export default {
GlSkeletonLoader, GlSkeletonLoader,
GlSearchBoxByClick, GlSearchBoxByClick,
RegistryHeader, RegistryHeader,
DeleteImage,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -179,30 +180,6 @@ export default { ...@@ -179,30 +180,6 @@ export default {
this.itemToDelete = item; this.itemToDelete = item;
this.$refs.deleteModal.show(); 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() { dismissDeleteAlert() {
this.deleteAlertType = null; this.deleteAlertType = null;
this.itemToDelete = {}; this.itemToDelete = {};
...@@ -250,6 +227,10 @@ export default { ...@@ -250,6 +227,10 @@ export default {
}); });
} }
}, },
startDelete() {
this.track('confirm_delete');
this.mutationLoading = true;
},
}, },
}; };
</script> </script>
...@@ -358,11 +339,19 @@ export default { ...@@ -358,11 +339,19 @@ export default {
</template> </template>
</template> </template>
<delete-image
:id="itemToDelete.id"
@start="startDelete"
@error="deleteAlertType = 'danger'"
@success="deleteAlertType = 'success'"
@end="mutationLoading = false"
>
<template #default="{ doDelete }">
<gl-modal <gl-modal
ref="deleteModal" ref="deleteModal"
modal-id="delete-image-modal" modal-id="delete-image-modal"
ok-variant="danger" :action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }"
@ok="handleDeleteImage" @primary="doDelete"
@cancel="track('cancel_delete')" @cancel="track('cancel_delete')"
> >
<template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template> <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
...@@ -373,8 +362,9 @@ export default { ...@@ -373,8 +362,9 @@ export default {
</template> </template>
</gl-sprintf> </gl-sprintf>
</p> </p>
<template #modal-ok>{{ __('Remove') }}</template>
</gl-modal> </gl-modal>
</template> </template>
</delete-image>
</template>
</div> </div>
</template> </template>
...@@ -7585,9 +7585,15 @@ msgstr "" ...@@ -7585,9 +7585,15 @@ msgstr ""
msgid "ContainerRegistry|Copy push command" msgid "ContainerRegistry|Copy push command"
msgstr "" msgstr ""
msgid "ContainerRegistry|Delete image repository?"
msgstr ""
msgid "ContainerRegistry|Delete selected" msgid "ContainerRegistry|Delete selected"
msgstr "" 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." msgid "ContainerRegistry|Deletion disabled due to missing or insufficient permissions."
msgstr "" msgstr ""
...@@ -7615,6 +7621,12 @@ msgstr "" ...@@ -7615,6 +7621,12 @@ msgstr ""
msgid "ContainerRegistry|Image Repositories" msgid "ContainerRegistry|Image Repositories"
msgstr "" msgstr ""
msgid "ContainerRegistry|Image repository deletion failed"
msgstr ""
msgid "ContainerRegistry|Image repository will be deleted"
msgstr ""
msgid "ContainerRegistry|Image tags" msgid "ContainerRegistry|Image tags"
msgstr "" msgstr ""
...@@ -7707,6 +7719,9 @@ msgstr "" ...@@ -7707,6 +7719,9 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again." msgid "ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again."
msgstr "" msgstr ""
msgid "ContainerRegistry|Something went wrong while scheduling the image for deletion."
msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the cleanup policy." msgid "ContainerRegistry|Something went wrong while updating the cleanup policy."
msgstr "" msgstr ""
...@@ -7752,9 +7767,15 @@ msgstr "" ...@@ -7752,9 +7767,15 @@ msgstr ""
msgid "ContainerRegistry|This image has no active tags" msgid "ContainerRegistry|This image has no active tags"
msgstr "" msgstr ""
msgid "ContainerRegistry|This image repository has failed to be deleted"
msgstr ""
msgid "ContainerRegistry|This image repository is scheduled for deletion" msgid "ContainerRegistry|This image repository is scheduled for deletion"
msgstr "" 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." msgid "ContainerRegistry|This project's cleanup policy for tags is not enabled."
msgstr "" 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 ...@@ -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 ProjectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue';
import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue'; import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue';
import ImageList from '~/registry/explorer/components/list_page/image_list.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 TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { import {
...@@ -27,7 +28,6 @@ import { ...@@ -27,7 +28,6 @@ import {
graphQLImageListMock, graphQLImageListMock,
graphQLImageDeleteMock, graphQLImageDeleteMock,
deletedContainerRepository, deletedContainerRepository,
graphQLImageDeleteMockError,
graphQLEmptyImageListMock, graphQLEmptyImageListMock,
graphQLEmptyGroupImageListMock, graphQLEmptyGroupImageListMock,
pageInfo, pageInfo,
...@@ -58,6 +58,7 @@ describe('List Page', () => { ...@@ -58,6 +58,7 @@ describe('List Page', () => {
const findListHeader = () => wrapper.find('[data-testid="listHeader"]'); const findListHeader = () => wrapper.find('[data-testid="listHeader"]');
const findSearchBox = () => wrapper.find(GlSearchBoxByClick); const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const findDeleteImage = () => wrapper.find(DeleteImage);
const waitForApolloRequestRender = async () => { const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
...@@ -91,6 +92,7 @@ describe('List Page', () => { ...@@ -91,6 +92,7 @@ describe('List Page', () => {
GlSprintf, GlSprintf,
RegistryHeader, RegistryHeader,
TitleArea, TitleArea,
DeleteImage,
}, },
mocks: { mocks: {
$toast, $toast,
...@@ -300,23 +302,22 @@ describe('List Page', () => { ...@@ -300,23 +302,22 @@ describe('List Page', () => {
}); });
describe('delete image', () => { describe('delete image', () => {
const deleteImage = async () => { const selectImageForDeletion = async () => {
await wrapper.vm.$nextTick(); await waitForApolloRequestRender();
findImageList().vm.$emit('delete', deletedContainerRepository); findImageList().vm.$emit('delete', deletedContainerRepository);
findDeleteModal().vm.$emit('ok');
await waitForApolloRequestRender();
}; };
it('should call deleteItem when confirming deletion', async () => { it('should call deleteItem when confirming deletion', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
mountComponent({ mutationResolver }); mountComponent({ mutationResolver });
await deleteImage(); await selectImageForDeletion();
findDeleteModal().vm.$emit('primary');
await waitForApolloRequestRender();
expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository); expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository);
expect(mutationResolver).toHaveBeenCalledWith({ id: deletedContainerRepository.id });
const updatedImage = findImageList() const updatedImage = findImageList()
.props('images') .props('images')
...@@ -326,10 +327,12 @@ describe('List Page', () => { ...@@ -326,10 +327,12 @@ describe('List Page', () => {
}); });
it('should show a success alert when delete request is successful', async () => { it('should show a success alert when delete request is successful', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); mountComponent();
mountComponent({ mutationResolver });
await deleteImage(); await selectImageForDeletion();
findDeleteImage().vm.$emit('success');
await wrapper.vm.$nextTick();
const alert = findDeleteAlert(); const alert = findDeleteAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
...@@ -340,23 +343,12 @@ describe('List Page', () => { ...@@ -340,23 +343,12 @@ describe('List Page', () => {
describe('when delete request fails it shows an alert', () => { describe('when delete request fails it shows an alert', () => {
it('user recoverable error', async () => { it('user recoverable error', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMockError); mountComponent();
mountComponent({ mutationResolver });
await deleteImage();
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 () => { await selectImageForDeletion();
const mutationResolver = jest.fn().mockRejectedValue();
mountComponent({ mutationResolver });
await deleteImage(); findDeleteImage().vm.$emit('error');
await wrapper.vm.$nextTick();
const alert = findDeleteAlert(); const alert = findDeleteAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
...@@ -499,9 +491,8 @@ describe('List Page', () => { ...@@ -499,9 +491,8 @@ describe('List Page', () => {
testTrackingCall('cancel_delete'); testTrackingCall('cancel_delete');
}); });
it('send an event when confirm is clicked on modal', () => { it('send an event when the deletion starts', () => {
const deleteModal = findDeleteModal(); findDeleteImage().vm.$emit('start');
deleteModal.vm.$emit('ok');
testTrackingCall('confirm_delete'); 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