Commit 3298aaee authored by Phil Hughes's avatar Phil Hughes

Merge branch '208429-implement-async-delete' into 'master'

Async delete in the container repository list

See merge request gitlab-org/gitlab!29175
parents 11814d16 7175a9c3
...@@ -53,7 +53,6 @@ export default { ...@@ -53,7 +53,6 @@ export default {
:primary-button-text="alertConfiguration.primaryButton" :primary-button-text="alertConfiguration.primaryButton"
:primary-button-link="config.settingsPath" :primary-button-link="config.settingsPath"
:title="alertConfiguration.title" :title="alertConfiguration.title"
class="my-2"
> >
<gl-sprintf :message="alertConfiguration.message"> <gl-sprintf :message="alertConfiguration.message">
<template #days> <template #days>
......
import { s__ } from '~/locale'; import { s__ } from '~/locale';
// List page
export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
);
export const LIST_INTRO_TEXT = s__(
`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
);
export const LIST_DELETE_BUTTON_DISABLED = s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
);
export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
);
export const ROW_SCHEDULED_FOR_DELETION = s__(
`ContainerRegistry|This image repository is scheduled for deletion`,
);
export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the packages list.', 'ContainerRegistry|Something went wrong while fetching the repository list.',
); );
export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the tags list.', 'ContainerRegistry|Something went wrong while fetching the tags list.',
); );
export const DELETE_IMAGE_ERROR_MESSAGE = s__( export const DELETE_IMAGE_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while deleting the image.', 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.',
); );
export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully'); export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
`ContainerRegistry|There was an error during the deletion of this image repository, please try again.`,
);
export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
'ContainerRegistry|%{title} was successfully scheduled for deletion',
);
// Image details page
export const DELETE_TAG_ERROR_MESSAGE = s__( export const DELETE_TAG_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while deleting the tag.', 'ContainerRegistry|Something went wrong while deleting the tag.',
); );
...@@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); ...@@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
// Expiration policies
export const EXPIRATION_POLICY_ALERT_TITLE = s__( export const EXPIRATION_POLICY_ALERT_TITLE = s__(
'ContainerRegistry|Retention policy has been Enabled', 'ContainerRegistry|Retention policy has been Enabled',
); );
...@@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( ...@@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__(
'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}',
); );
// Quick Start
export const QUICK_START = s__('ContainerRegistry|Quick Start'); export const QUICK_START = s__('ContainerRegistry|Quick Start');
export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
...@@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); ...@@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
// Image state
export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
...@@ -9,16 +9,28 @@ import { ...@@ -9,16 +9,28 @@ import {
GlModal, GlModal,
GlSprintf, GlSprintf,
GlLink, GlLink,
GlAlert,
GlSkeletonLoader, GlSkeletonLoader,
} from '@gitlab/ui'; } from '@gitlab/ui';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ProjectEmptyState from '../components/project_empty_state.vue'; import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue';
import ProjectPolicyAlert from '../components/project_policy_alert.vue'; import ProjectPolicyAlert from '../components/project_policy_alert.vue';
import QuickstartDropdown from '../components/quickstart_dropdown.vue'; import QuickstartDropdown from '../components/quickstart_dropdown.vue';
import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants'; import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
LIST_INTRO_TEXT,
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
REMOVE_REPOSITORY_MODAL_TEXT,
ROW_SCHEDULED_FOR_DELETION,
} from '../constants';
export default { export default {
name: 'RegistryListApp', name: 'RegistryListApp',
...@@ -35,6 +47,7 @@ export default { ...@@ -35,6 +47,7 @@ export default {
GlModal, GlModal,
GlSprintf, GlSprintf,
GlLink, GlLink,
GlAlert,
GlSkeletonLoader, GlSkeletonLoader,
}, },
directives: { directives: {
...@@ -47,25 +60,20 @@ export default { ...@@ -47,25 +60,20 @@ export default {
height: 40, height: 40,
}, },
i18n: { i18n: {
containerRegistryTitle: s__('ContainerRegistry|Container Registry'), containerRegistryTitle: CONTAINER_REGISTRY_TITLE,
connectionErrorTitle: s__('ContainerRegistry|Docker connection error'), connectionErrorTitle: CONNECTION_ERROR_TITLE,
connectionErrorMessage: s__( connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, introText: LIST_INTRO_TEXT,
), deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED,
introText: s__( removeRepositoryLabel: REMOVE_REPOSITORY_LABEL,
`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT,
), rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION,
deleteButtonDisabled: s__( asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
),
removeRepositoryLabel: s__('ContainerRegistry|Remove repository'),
removeRepositoryModalText: s__(
'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
),
}, },
data() { data() {
return { return {
itemToDelete: {}, itemToDelete: {},
deleteAlertType: null,
}; };
}, },
computed: { computed: {
...@@ -86,43 +94,61 @@ export default { ...@@ -86,43 +94,61 @@ export default {
showQuickStartDropdown() { showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
}, },
showDeleteAlert() {
return this.deleteAlertType && this.itemToDelete?.path;
},
deleteImageAlertMessage() {
return this.deleteAlertType === 'success'
? DELETE_IMAGE_SUCCESS_MESSAGE
: DELETE_IMAGE_ERROR_MESSAGE;
},
}, },
methods: { methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']), ...mapActions(['requestImagesList', 'requestDeleteImage']),
deleteImage(item) { deleteImage(item) {
// This event is already tracked in the system and so the name must be kept to aggregate the data
this.track('click_button'); this.track('click_button');
this.itemToDelete = item; this.itemToDelete = item;
this.$refs.deleteModal.show(); this.$refs.deleteModal.show();
}, },
handleDeleteImage() { handleDeleteImage() {
this.track('confirm_delete'); this.track('confirm_delete');
return this.requestDeleteImage(this.itemToDelete.destroy_path) return this.requestDeleteImage(this.itemToDelete)
.then(() => .then(() => {
this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, { this.deleteAlertType = 'success';
type: 'success', })
}), .catch(() => {
) this.deleteAlertType = 'danger';
.catch(() =>
this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, {
type: 'error',
}),
)
.finally(() => {
this.itemToDelete = {};
}); });
}, },
encodeListItem(item) { encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
return window.btoa(params); return window.btoa(params);
}, },
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
},
}, },
}; };
</script> </script>
<template> <template>
<div class="w-100 slide-enter-from-element"> <div class="w-100 slide-enter-from-element">
<project-policy-alert v-if="!config.isGroupPage" /> <gl-alert
v-if="showDeleteAlert"
:variant="deleteAlertType"
class="mt-2"
dismissible
@dismiss="dismissDeleteAlert"
>
<gl-sprintf :message="deleteImageAlertMessage">
<template #title>
{{ itemToDelete.path }}
</template>
</gl-sprintf>
</gl-alert>
<project-policy-alert v-if="!config.isGroupPage" class="mt-2" />
<gl-empty-state <gl-empty-state
v-if="config.characterError" v-if="config.characterError"
...@@ -178,41 +204,57 @@ export default { ...@@ -178,41 +204,57 @@ export default {
v-for="(listItem, index) in images" v-for="(listItem, index) in images"
:key="index" :key="index"
ref="rowItem" ref="rowItem"
:class="{ 'border-top': index === 0 }" v-gl-tooltip="{
class="d-flex justify-content-between align-items-center py-2 border-bottom" placement: 'left',
disabled: !listItem.deleting,
title: $options.i18n.rowScheduledForDeletion,
}"
> >
<div>
<router-link
ref="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
ref="clipboardButton"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
<div <div
v-gl-tooltip="{ disabled: listItem.destroy_path }" class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom"
class="d-none d-sm-block" :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
:title="$options.i18n.deleteButtonDisabled"
> >
<gl-deprecated-button <div class="d-felx align-items-center">
ref="deleteImageButton" <router-link
v-gl-tooltip ref="detailsLink"
:disabled="!listItem.destroy_path" :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
:title="$options.i18n.removeRepositoryLabel" >
:aria-label="$options.i18n.removeRepositoryLabel" {{ listItem.path }}
class="btn-inverted" </router-link>
variant="danger" <clipboard-button
@click="deleteImage(listItem)" v-if="listItem.location"
ref="clipboardButton"
:disabled="listItem.deleting"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
<gl-icon
v-if="listItem.failedDelete"
v-gl-tooltip
:title="$options.i18n.asyncDeleteErrorMessage"
name="warning"
class="text-warning align-middle"
/>
</div>
<div
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="$options.i18n.deleteButtonDisabled"
> >
<gl-icon name="remove" /> <gl-deprecated-button
</gl-deprecated-button> ref="deleteImageButton"
v-gl-tooltip
:disabled="!listItem.destroy_path || listItem.deleting"
:title="$options.i18n.removeRepositoryLabel"
:aria-label="$options.i18n.removeRepositoryLabel"
class="btn-inverted"
variant="danger"
@click="deleteImage(listItem)"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</div>
</div> </div>
</div> </div>
<gl-pagination <gl-pagination
......
...@@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) ...@@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params })
}); });
}; };
export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => { export const requestDeleteImage = ({ commit }, image) => {
commit(types.SET_MAIN_LOADING, true); commit(types.SET_MAIN_LOADING, true);
return axios return axios
.delete(destroyPath) .delete(image.destroy_path)
.then(() => { .then(() => {
dispatch('setShowGarbageCollectionTip', true); commit(types.UPDATE_IMAGE, { ...image, deleting: true });
dispatch('requestImagesList', { pagination: state.pagination });
}) })
.finally(() => { .finally(() => {
commit(types.SET_MAIN_LOADING, false); commit(types.SET_MAIN_LOADING, false);
......
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
export const UPDATE_IMAGE = 'UPDATE_IMAGE';
export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants';
export default { export default {
[types.SET_INITIAL_STATE](state, config) { [types.SET_INITIAL_STATE](state, config) {
...@@ -12,7 +13,17 @@ export default { ...@@ -12,7 +13,17 @@ export default {
}, },
[types.SET_IMAGES_LIST_SUCCESS](state, images) { [types.SET_IMAGES_LIST_SUCCESS](state, images) {
state.images = images; state.images = images.map(i => ({
...i,
status: undefined,
deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS,
failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS,
}));
},
[types.UPDATE_IMAGE](state, image) {
const index = state.images.findIndex(i => i.id === image.id);
state.images.splice(index, 1, { ...image });
}, },
[types.SET_TAGS_LIST_SUCCESS](state, tags) { [types.SET_TAGS_LIST_SUCCESS](state, tags) {
......
---
title: Enable async delete in container repository list
merge_request: 29175
author:
type: changed
...@@ -5468,6 +5468,9 @@ msgstr "" ...@@ -5468,6 +5468,9 @@ msgstr ""
msgid "ContainerRegistry|%{imageName} tags" msgid "ContainerRegistry|%{imageName} tags"
msgstr "" msgstr ""
msgid "ContainerRegistry|%{title} was successfully scheduled for deletion"
msgstr ""
msgid "ContainerRegistry|Automatically remove extra images that aren't designed to be kept." msgid "ContainerRegistry|Automatically remove extra images that aren't designed to be kept."
msgstr "" msgstr ""
...@@ -5522,9 +5525,6 @@ msgstr "" ...@@ -5522,9 +5525,6 @@ msgstr ""
msgid "ContainerRegistry|Image ID" msgid "ContainerRegistry|Image ID"
msgstr "" msgstr ""
msgid "ContainerRegistry|Image deleted successfully"
msgstr ""
msgid "ContainerRegistry|Keep and protect the images that matter most." msgid "ContainerRegistry|Keep and protect the images that matter most."
msgstr "" msgstr ""
...@@ -5563,9 +5563,6 @@ msgstr[1] "" ...@@ -5563,9 +5563,6 @@ msgstr[1] ""
msgid "ContainerRegistry|Retention policy has been Enabled" msgid "ContainerRegistry|Retention policy has been Enabled"
msgstr "" msgstr ""
msgid "ContainerRegistry|Something went wrong while deleting the image."
msgstr ""
msgid "ContainerRegistry|Something went wrong while deleting the tag." msgid "ContainerRegistry|Something went wrong while deleting the tag."
msgstr "" msgstr ""
...@@ -5575,12 +5572,15 @@ msgstr "" ...@@ -5575,12 +5572,15 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the expiration policy." msgid "ContainerRegistry|Something went wrong while fetching the expiration policy."
msgstr "" msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the packages list." msgid "ContainerRegistry|Something went wrong while fetching the repository list."
msgstr "" msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the tags list." msgid "ContainerRegistry|Something went wrong while fetching the tags list."
msgstr "" msgstr ""
msgid "ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again."
msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the expiration policy." msgid "ContainerRegistry|Something went wrong while updating the expiration policy."
msgstr "" msgstr ""
...@@ -5620,12 +5620,18 @@ msgstr "" ...@@ -5620,12 +5620,18 @@ msgstr ""
msgid "ContainerRegistry|There are no container images stored for this project" msgid "ContainerRegistry|There are no container images stored for this project"
msgstr "" msgstr ""
msgid "ContainerRegistry|There was an error during the deletion of this image repository, please try again."
msgstr ""
msgid "ContainerRegistry|This Registry contains deleted image tag data. Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage." msgid "ContainerRegistry|This Registry contains deleted image tag data. Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage."
msgstr "" msgstr ""
msgid "ContainerRegistry|This image has no active tags" msgid "ContainerRegistry|This image has no active tags"
msgstr "" msgstr ""
msgid "ContainerRegistry|This image repository is scheduled for deletion"
msgstr ""
msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}" msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}"
msgstr "" msgstr ""
......
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlPagination, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; import { GlPagination, GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue'; import component from '~/registry/explorer/pages/list.vue';
import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue'; import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue';
import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue'; import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue';
import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue'; import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue';
import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue';
import store from '~/registry/explorer/stores/'; import store from '~/registry/explorer/stores/';
import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/'; import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
import { import {
...@@ -35,6 +36,8 @@ describe('List Page', () => { ...@@ -35,6 +36,8 @@ describe('List Page', () => {
const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown); const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown);
const findProjectEmptyState = () => wrapper.find(ProjectEmptyState); const findProjectEmptyState = () => wrapper.find(ProjectEmptyState);
const findGroupEmptyState = () => wrapper.find(GroupEmptyState); const findGroupEmptyState = () => wrapper.find(GroupEmptyState);
const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert);
const findDeleteAlert = () => wrapper.find(GlAlert);
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
...@@ -57,6 +60,18 @@ describe('List Page', () => { ...@@ -57,6 +60,18 @@ describe('List Page', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('Expiration policy notification', () => {
it('shows up on project page', () => {
expect(findProjectPolicyAlert().exists()).toBe(true);
});
it('does show up on group page', () => {
store.dispatch('setInitialState', { isGroupPage: true });
return wrapper.vm.$nextTick().then(() => {
expect(findProjectPolicyAlert().exists()).toBe(false);
});
});
});
describe('connection error', () => { describe('connection error', () => {
const config = { const config = {
characterError: true, characterError: true,
...@@ -179,32 +194,38 @@ describe('List Page', () => { ...@@ -179,32 +194,38 @@ describe('List Page', () => {
it('should call deleteItem when confirming deletion', () => { it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue(); dispatchSpy.mockResolvedValue();
const itemToDelete = wrapper.vm.images[0]; findDeleteBtn().vm.$emit('click');
wrapper.setData({ itemToDelete }); expect(wrapper.vm.itemToDelete).not.toEqual({});
findDeleteModal().vm.$emit('ok'); findDeleteModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith( expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage', 'requestDeleteImage',
itemToDelete.destroy_path, wrapper.vm.itemToDelete,
); );
}); });
it('should show a success toast when delete request is successful', () => { it('should show a success alert when delete request is successful', () => {
dispatchSpy.mockResolvedValue(); dispatchSpy.mockResolvedValue();
findDeleteBtn().vm.$emit('click');
expect(wrapper.vm.itemToDelete).not.toEqual({});
return wrapper.vm.handleDeleteImage().then(() => { return wrapper.vm.handleDeleteImage().then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_IMAGE_SUCCESS_MESSAGE, { const alert = findDeleteAlert();
type: 'success', expect(alert.exists()).toBe(true);
}); expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
expect(wrapper.vm.itemToDelete).toEqual({}); DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
}); });
}); });
it('should show a error toast when delete request fails', () => { it('should show an error alert when delete request fails', () => {
dispatchSpy.mockRejectedValue(); dispatchSpy.mockRejectedValue();
findDeleteBtn().vm.$emit('click');
expect(wrapper.vm.itemToDelete).not.toEqual({});
return wrapper.vm.handleDeleteImage().then(() => { return wrapper.vm.handleDeleteImage().then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_IMAGE_ERROR_MESSAGE, { const alert = findDeleteAlert();
type: 'error', expect(alert.exists()).toBe(true);
}); expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
expect(wrapper.vm.itemToDelete).toEqual({}); DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
}); });
}); });
}); });
......
...@@ -279,39 +279,32 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -279,39 +279,32 @@ describe('Actions RegistryExplorer Store', () => {
}); });
describe('request delete single image', () => { describe('request delete single image', () => {
const deletePath = 'delete/path'; const image = {
destroy_path: 'delete/path',
};
it('successfully performs the delete request', done => { it('successfully performs the delete request', done => {
mock.onDelete(deletePath).replyOnce(200); mock.onDelete(image.destroy_path).replyOnce(200);
testAction( testAction(
actions.requestDeleteImage, actions.requestDeleteImage,
deletePath, image,
{ {},
pagination: {},
},
[ [
{ type: types.SET_MAIN_LOADING, payload: true }, { type: types.SET_MAIN_LOADING, payload: true },
{ type: types.UPDATE_IMAGE, payload: { ...image, deleting: true } },
{ type: types.SET_MAIN_LOADING, payload: false }, { type: types.SET_MAIN_LOADING, payload: false },
], ],
[ [],
{
type: 'setShowGarbageCollectionTip',
payload: true,
},
{
type: 'requestImagesList',
payload: { pagination: {} },
},
],
done, done,
); );
}); });
it('should turn off loading on error', done => { it('should turn off loading on error', done => {
mock.onDelete(deletePath).replyOnce(400); mock.onDelete(image.destroy_path).replyOnce(400);
testAction( testAction(
actions.requestDeleteImage, actions.requestDeleteImage,
deletePath, image,
{}, {},
[ [
{ type: types.SET_MAIN_LOADING, payload: true }, { type: types.SET_MAIN_LOADING, payload: true },
......
...@@ -28,14 +28,32 @@ describe('Mutations Registry Explorer Store', () => { ...@@ -28,14 +28,32 @@ describe('Mutations Registry Explorer Store', () => {
describe('SET_IMAGES_LIST_SUCCESS', () => { describe('SET_IMAGES_LIST_SUCCESS', () => {
it('should set the images list', () => { it('should set the images list', () => {
const images = [1, 2, 3]; const images = [{ name: 'foo' }, { name: 'bar' }];
const expectedState = { ...mockState, images }; const defaultStatus = { deleting: false, failedDelete: false };
const expectedState = {
...mockState,
images: [{ name: 'foo', ...defaultStatus }, { name: 'bar', ...defaultStatus }],
};
mutations[types.SET_IMAGES_LIST_SUCCESS](mockState, images); mutations[types.SET_IMAGES_LIST_SUCCESS](mockState, images);
expect(mockState).toEqual(expectedState); expect(mockState).toEqual(expectedState);
}); });
}); });
describe('UPDATE_IMAGE', () => {
it('should update an image', () => {
mockState.images = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }];
const payload = { id: 1, name: 'baz' };
const expectedState = {
...mockState,
images: [payload, { id: 2, name: 'bar' }],
};
mutations[types.UPDATE_IMAGE](mockState, payload);
expect(mockState).toEqual(expectedState);
});
});
describe('SET_TAGS_LIST_SUCCESS', () => { describe('SET_TAGS_LIST_SUCCESS', () => {
it('should set the tags list', () => { it('should set the tags list', () => {
const tags = [1, 2, 3]; const tags = [1, 2, 3];
......
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