Commit 0e569d16 authored by Mark Florian's avatar Mark Florian

Merge branch...

Merge branch '216761-add-image-repository-level-delete-functionality-to-the-image-repository-detail-view' into 'master'

Add delete functionality to the Image Repository detail view

See merge request gitlab-org/gitlab!51980
parents a7ad7fca c0ec6ddc
<script> <script>
import { GlModal, GlSprintf } from '@gitlab/ui'; import { GlModal, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import { REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAGS_CONFIRMATION_TEXT } from '../../constants/index'; import {
REMOVE_TAG_CONFIRMATION_TEXT,
REMOVE_TAGS_CONFIRMATION_TEXT,
DELETE_IMAGE_CONFIRMATION_TITLE,
DELETE_IMAGE_CONFIRMATION_TEXT,
} from '../../constants';
export default { export default {
components: { components: {
...@@ -14,9 +19,17 @@ export default { ...@@ -14,9 +19,17 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
deleteImage: {
type: Boolean,
default: false,
required: false,
},
}, },
computed: { computed: {
modalAction() { modalTitle() {
if (this.deleteImage) {
return DELETE_IMAGE_CONFIRMATION_TITLE;
}
return n__( return n__(
'ContainerRegistry|Remove tag', 'ContainerRegistry|Remove tag',
'ContainerRegistry|Remove tags', 'ContainerRegistry|Remove tags',
...@@ -24,14 +37,19 @@ export default { ...@@ -24,14 +37,19 @@ export default {
); );
}, },
modalDescription() { modalDescription() {
if (this.deleteImage) {
return {
message: DELETE_IMAGE_CONFIRMATION_TEXT,
};
}
if (this.itemsToBeDeleted.length > 1) { if (this.itemsToBeDeleted.length > 1) {
return { return {
message: REMOVE_TAGS_CONFIRMATION_TEXT, message: REMOVE_TAGS_CONFIRMATION_TEXT,
item: this.itemsToBeDeleted.length, item: this.itemsToBeDeleted.length,
}; };
} }
const [first] = this.itemsToBeDeleted;
const [first] = this.itemsToBeDeleted;
return { return {
message: REMOVE_TAG_CONFIRMATION_TEXT, message: REMOVE_TAG_CONFIRMATION_TEXT,
item: first?.path, item: first?.path,
...@@ -51,16 +69,17 @@ export default { ...@@ -51,16 +69,17 @@ export default {
ref="deleteModal" ref="deleteModal"
modal-id="delete-tag-modal" modal-id="delete-tag-modal"
ok-variant="danger" ok-variant="danger"
@ok="$emit('confirmDelete')" :action-primary="{ text: __('Confirm'), attributes: { variant: 'danger' } }"
:action-cancel="{ text: __('Cancel') }"
@primary="$emit('confirmDelete')"
@cancel="$emit('cancelDelete')" @cancel="$emit('cancelDelete')"
> >
<template #modal-title>{{ modalAction }}</template> <template #modal-title>{{ modalTitle }}</template>
<template #modal-ok>{{ modalAction }}</template>
<p v-if="modalDescription" data-testid="description"> <p v-if="modalDescription" data-testid="description">
<gl-sprintf :message="modalDescription.message"> <gl-sprintf :message="modalDescription.message">
<template #item <template #item>
><b>{{ modalDescription.item }}</b></template <b>{{ modalDescription.item }}</b>
> </template>
</gl-sprintf> </gl-sprintf>
</p> </p>
</gl-modal> </gl-modal>
......
<script> <script>
import { GlSprintf } from '@gitlab/ui'; import { GlSprintf, GlButton } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale'; import { sprintf, n__ } from '~/locale';
import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
...@@ -24,7 +24,7 @@ import { ...@@ -24,7 +24,7 @@ import {
export default { export default {
name: 'DetailsHeader', name: 'DetailsHeader',
components: { GlSprintf, TitleArea, MetadataItem }, components: { GlSprintf, GlButton, TitleArea, MetadataItem },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
image: { image: {
...@@ -36,6 +36,11 @@ export default { ...@@ -36,6 +36,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
required: false,
},
}, },
computed: { computed: {
visibilityIcon() { visibilityIcon() {
...@@ -65,6 +70,9 @@ export default { ...@@ -65,6 +70,9 @@ export default {
[UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP }, [UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP },
}[this.image?.expirationPolicyCleanupStatus]; }[this.image?.expirationPolicyCleanupStatus];
}, },
deleteButtonDisabled() {
return this.disabled || !this.image.canDelete;
},
}, },
i18n: { i18n: {
DETAILS_PAGE_TITLE, DETAILS_PAGE_TITLE,
...@@ -75,11 +83,13 @@ export default { ...@@ -75,11 +83,13 @@ export default {
<template> <template>
<title-area :metadata-loading="metadataLoading"> <title-area :metadata-loading="metadataLoading">
<template #title> <template #title>
<gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> <span data-testid="title">
<template #imageName> <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
{{ image.name }} <template #imageName>
</template> {{ image.name }}
</gl-sprintf> </template>
</gl-sprintf>
</span>
</template> </template>
<template #metadata-tags-count> <template #metadata-tags-count>
<metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
...@@ -103,5 +113,15 @@ export default { ...@@ -103,5 +113,15 @@ export default {
data-testid="updated-and-visibility" data-testid="updated-and-visibility"
/> />
</template> </template>
<template #right-actions>
<gl-button
v-if="!metadataLoading"
variant="danger"
:disabled="deleteButtonDisabled"
@click="$emit('delete')"
>
{{ __('Delete') }}
</gl-button>
</template>
</title-area> </title-area>
</template> </template>
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import {
IMAGE_STATUS_MESSAGES,
IMAGE_STATUS_TITLES,
IMAGE_STATUS_ALERT_TYPE,
PACKAGE_DELETE_HELP_PAGE_PATH,
} from '../../constants';
export default {
components: {
GlAlert,
GlSprintf,
GlLink,
},
props: {
status: {
type: String,
required: true,
},
},
computed: {
message() {
return IMAGE_STATUS_MESSAGES[this.status];
},
title() {
return IMAGE_STATUS_TITLES[this.status];
},
variant() {
return IMAGE_STATUS_ALERT_TYPE[this.status];
},
},
links: {
PACKAGE_DELETE_HELP_PAGE_PATH,
},
};
</script>
<template>
<gl-alert :title="title" :variant="variant">
<span data-testid="message">
<gl-sprintf :message="message">
<template #link="{ content }">
<gl-link :href="$options.links.PACKAGE_DELETE_HELP_PAGE_PATH" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</span>
</gl-alert>
</template>
...@@ -20,6 +20,11 @@ export default { ...@@ -20,6 +20,11 @@ export default {
default: true, default: true,
required: false, required: false,
}, },
disabled: {
type: Boolean,
default: false,
required: false,
},
}, },
i18n: { i18n: {
REMOVE_TAGS_BUTTON_TITLE, REMOVE_TAGS_BUTTON_TITLE,
...@@ -37,6 +42,9 @@ export default { ...@@ -37,6 +42,9 @@ export default {
showMultiDeleteButton() { showMultiDeleteButton() {
return this.tags.some((tag) => tag.canDelete) && !this.isMobile; return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
}, },
multiDeleteButtonIsDisabled() {
return !this.hasSelectedItems || this.disabled;
},
}, },
methods: { methods: {
updateSelectedItems(name) { updateSelectedItems(name) {
...@@ -55,7 +63,7 @@ export default { ...@@ -55,7 +63,7 @@ export default {
<gl-button <gl-button
v-if="showMultiDeleteButton" v-if="showMultiDeleteButton"
:disabled="!hasSelectedItems" :disabled="multiDeleteButtonIsDisabled"
category="secondary" category="secondary"
variant="danger" variant="danger"
@click="$emit('delete', selectedItems)" @click="$emit('delete', selectedItems)"
...@@ -70,6 +78,7 @@ export default { ...@@ -70,6 +78,7 @@ export default {
:first="index === 0" :first="index === 0"
:selected="selectedItems[tag.name]" :selected="selectedItems[tag.name]"
:is-mobile="isMobile" :is-mobile="isMobile"
:disabled="disabled"
@select="updateSelectedItems(tag.name)" @select="updateSelectedItems(tag.name)"
@delete="$emit('delete', { [tag.name]: true })" @delete="$emit('delete', { [tag.name]: true })"
/> />
......
...@@ -12,6 +12,8 @@ import DetailsHeader from '../components/details_page/details_header.vue'; ...@@ -12,6 +12,8 @@ import DetailsHeader from '../components/details_page/details_header.vue';
import TagsList from '../components/details_page/tags_list.vue'; import TagsList from '../components/details_page/tags_list.vue';
import TagsLoader from '../components/details_page/tags_loader.vue'; import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyState from '../components/details_page/empty_state.vue'; import EmptyState from '../components/details_page/empty_state.vue';
import StatusAlert from '../components/details_page/status_alert.vue';
import DeleteImage from '../components/delete_image.vue';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
...@@ -21,6 +23,7 @@ import { ...@@ -21,6 +23,7 @@ import {
ALERT_DANGER_TAG, ALERT_DANGER_TAG,
ALERT_SUCCESS_TAGS, ALERT_SUCCESS_TAGS,
ALERT_DANGER_TAGS, ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
GRAPHQL_PAGE_SIZE, GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE, FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS, UNFINISHED_STATUS,
...@@ -38,6 +41,8 @@ export default { ...@@ -38,6 +41,8 @@ export default {
TagsList, TagsList,
TagsLoader, TagsLoader,
EmptyState, EmptyState,
StatusAlert,
DeleteImage,
}, },
directives: { directives: {
GlResizeObserver: GlResizeObserverDirective, GlResizeObserver: GlResizeObserverDirective,
...@@ -71,6 +76,7 @@ export default { ...@@ -71,6 +76,7 @@ export default {
mutationLoading: false, mutationLoading: false,
deleteAlertType: null, deleteAlertType: null,
hidePartialCleanupWarning: false, hidePartialCleanupWarning: false,
deleteImageAlert: false,
}; };
}, },
computed: { computed: {
...@@ -105,6 +111,9 @@ export default { ...@@ -105,6 +111,9 @@ export default {
hasNoTags() { hasNoTags() {
return this.tags.length === 0; return this.tags.length === 0;
}, },
pageActionsAreDisabled() {
return Boolean(this.image?.status);
},
}, },
methods: { methods: {
updateBreadcrumb() { updateBreadcrumb() {
...@@ -112,11 +121,19 @@ export default { ...@@ -112,11 +121,19 @@ export default {
this.breadCrumbState.updateName(name); this.breadCrumbState.updateName(name);
}, },
deleteTags(toBeDeleted) { deleteTags(toBeDeleted) {
this.deleteImageAlert = false;
this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]); this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]);
this.track('click_button'); this.track('click_button');
this.$refs.deleteModal.show(); this.$refs.deleteModal.show();
}, },
async handleDelete() { confirmDelete() {
if (this.deleteImageAlert) {
this.$refs.deleteImage.doDelete();
} else {
this.handleDeleteTag();
}
},
async handleDeleteTag() {
this.track('confirm_delete'); this.track('confirm_delete');
const { itemsToBeDeleted } = this; const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = []; this.itemsToBeDeleted = [];
...@@ -184,6 +201,18 @@ export default { ...@@ -184,6 +201,18 @@ export default {
feature_name: this.config.userCalloutId, feature_name: this.config.userCalloutId,
}); });
}, },
deleteImage() {
this.deleteImageAlert = true;
this.itemsToBeDeleted = [{ path: this.image.path }];
this.$refs.deleteModal.show();
},
deleteImageError() {
this.deleteAlertType = ALERT_DANGER_IMAGE;
},
deleteImageIniit() {
this.itemsToBeDeleted = [];
this.mutationLoading = true;
},
}, },
}; };
</script> </script>
...@@ -205,13 +234,25 @@ export default { ...@@ -205,13 +234,25 @@ export default {
@dismiss="dismissPartialCleanupWarning" @dismiss="dismissPartialCleanupWarning"
/> />
<details-header :image="image" :metadata-loading="isLoading" /> <status-alert v-if="image.status" :status="image.status" />
<details-header
:image="image"
:metadata-loading="isLoading"
:disabled="pageActionsAreDisabled"
@delete="deleteImage"
/>
<tags-loader v-if="isLoading" /> <tags-loader v-if="isLoading" />
<template v-else> <template v-else>
<empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
<template v-else> <template v-else>
<tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> <tags-list
:tags="tags"
:is-mobile="isMobile"
:disabled="pageActionsAreDisabled"
@delete="deleteTags"
/>
<div class="gl-display-flex gl-justify-content-center"> <div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination <gl-keyset-pagination
v-if="showPagination" v-if="showPagination"
...@@ -225,10 +266,20 @@ export default { ...@@ -225,10 +266,20 @@ export default {
</template> </template>
</template> </template>
<delete-image
:id="image.id"
ref="deleteImage"
use-update-fn
@start="deleteImageIniit"
@error="deleteImageError"
@end="mutationLoading = false"
/>
<delete-modal <delete-modal
ref="deleteModal" ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted" :items-to-be-deleted="itemsToBeDeleted"
@confirmDelete="handleDelete" :delete-image="deleteImageAlert"
@confirmDelete="confirmDelete"
@cancel="track('cancel_delete')" @cancel="track('cancel_delete')"
/> />
</template> </template>
......
---
title: Add delete functionality to the Image Repository detail view
merge_request: 51980
author:
type: added
...@@ -4,6 +4,8 @@ import component from '~/registry/explorer/components/details_page/delete_modal. ...@@ -4,6 +4,8 @@ import component from '~/registry/explorer/components/details_page/delete_modal.
import { import {
REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAG_CONFIRMATION_TEXT,
REMOVE_TAGS_CONFIRMATION_TEXT, REMOVE_TAGS_CONFIRMATION_TEXT,
DELETE_IMAGE_CONFIRMATION_TITLE,
DELETE_IMAGE_CONFIRMATION_TEXT,
} from '~/registry/explorer/constants'; } from '~/registry/explorer/constants';
import { GlModal } from '../../stubs'; import { GlModal } from '../../stubs';
...@@ -35,13 +37,13 @@ describe('Delete Modal', () => { ...@@ -35,13 +37,13 @@ describe('Delete Modal', () => {
describe('events', () => { describe('events', () => {
it.each` it.each`
glEvent | localEvent glEvent | localEvent
${'ok'} | ${'confirmDelete'} ${'primary'} | ${'confirmDelete'}
${'cancel'} | ${'cancelDelete'} ${'cancel'} | ${'cancelDelete'}
`('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => { `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => {
mountComponent(); mountComponent();
findModal().vm.$emit(glEvent); findModal().vm.$emit(glEvent);
expect(wrapper.emitted(localEvent)).toBeTruthy(); expect(wrapper.emitted(localEvent)).toEqual([[]]);
}); });
}); });
...@@ -53,27 +55,51 @@ describe('Delete Modal', () => { ...@@ -53,27 +55,51 @@ describe('Delete Modal', () => {
}); });
}); });
describe('itemsToBeDeleted contains one element', () => { describe('when we are deleting images', () => {
beforeEach(() => { it('has the correct title', () => {
mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); mountComponent({ deleteImage: true });
});
it(`has the correct description`, () => { expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE);
expect(findDescription().text()).toBe(REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'));
}); });
it('has the correct action', () => {
expect(wrapper.text()).toContain('Remove tag'); it('has the correct description', () => {
mountComponent({ deleteImage: true });
expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TEXT);
}); });
}); });
describe('itemsToBeDeleted contains more than element', () => { describe('when we are deleting tags', () => {
beforeEach(() => { describe('itemsToBeDeleted contains one element', () => {
mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] }); beforeEach(() => {
}); mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] });
it(`has the correct description`, () => { });
expect(findDescription().text()).toBe(REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'));
it(`has the correct description`, () => {
expect(findDescription().text()).toBe(
REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'),
);
});
it('has the correct title', () => {
expect(wrapper.text()).toContain('Remove tag');
});
}); });
it('has the correct action', () => {
expect(wrapper.text()).toContain('Remove tags'); describe('itemsToBeDeleted contains more than element', () => {
beforeEach(() => {
mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] });
});
it(`has the correct description`, () => {
expect(findDescription().text()).toBe(
REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'),
);
});
it('has the correct title', () => {
expect(wrapper.text()).toContain('Remove tags');
});
}); });
}); });
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui'; import { GlSprintf, GlButton } from '@gitlab/ui';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import component from '~/registry/explorer/components/details_page/details_header.vue'; import component from '~/registry/explorer/components/details_page/details_header.vue';
...@@ -23,6 +23,7 @@ describe('Details Header', () => { ...@@ -23,6 +23,7 @@ describe('Details Header', () => {
name: 'foo', name: 'foo',
updatedAt: '2020-11-03T13:29:21Z', updatedAt: '2020-11-03T13:29:21Z',
tagsCount: 10, tagsCount: 10,
canDelete: true,
project: { project: {
visibility: 'public', visibility: 'public',
containerExpirationPolicy: { containerExpirationPolicy: {
...@@ -36,8 +37,10 @@ describe('Details Header', () => { ...@@ -36,8 +37,10 @@ describe('Details Header', () => {
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility'); const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility');
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 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
...@@ -45,11 +48,9 @@ describe('Details Header', () => { ...@@ -45,11 +48,9 @@ describe('Details Header', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}; };
const mountComponent = (image = defaultImage) => { const mountComponent = (propsData = { image: defaultImage }) => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
propsData: { propsData,
image,
},
stubs: { stubs: {
GlSprintf, GlSprintf,
TitleArea, TitleArea,
...@@ -63,13 +64,65 @@ describe('Details Header', () => { ...@@ -63,13 +64,65 @@ describe('Details Header', () => {
}); });
it('has the correct title ', () => { it('has the correct title ', () => {
mountComponent({ ...defaultImage, name: '' }); mountComponent({ image: { ...defaultImage, name: '' } });
expect(wrapper.text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); expect(findTitle().text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE);
}); });
it('shows imageName in the title', () => { it('shows imageName in the title', () => {
mountComponent(); mountComponent();
expect(wrapper.text()).toContain('foo'); expect(findTitle().text()).toContain('foo');
});
describe('delete button', () => {
it('exists', () => {
mountComponent();
expect(findDeleteButton().exists()).toBe(true);
});
it('is hidden while loading', () => {
mountComponent({ image: defaultImage, metadataLoading: true });
expect(findDeleteButton().exists()).toBe(false);
});
it('has the correct text', () => {
mountComponent();
expect(findDeleteButton().text()).toBe('Delete');
});
it('has the correct props', () => {
mountComponent();
expect(findDeleteButton().props()).toMatchObject({
variant: 'danger',
disabled: false,
});
});
it('emits the correct event', () => {
mountComponent();
findDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[]]);
});
it.each`
canDelete | disabled | isDisabled
${true} | ${false} | ${false}
${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({ image: { ...defaultImage, canDelete }, disabled });
expect(findDeleteButton().props('disabled')).toBe(isDisabled);
},
);
}); });
describe('metadata items', () => { describe('metadata items', () => {
...@@ -82,7 +135,7 @@ describe('Details Header', () => { ...@@ -82,7 +135,7 @@ describe('Details Header', () => {
}); });
it('when there is one tag has the correct text', async () => { it('when there is one tag has the correct text', async () => {
mountComponent({ ...defaultImage, tagsCount: 1 }); mountComponent({ image: { ...defaultImage, tagsCount: 1 } });
await waitForMetadataItems(); await waitForMetadataItems();
expect(findTagsCount().props('text')).toBe('1 tag'); expect(findTagsCount().props('text')).toBe('1 tag');
...@@ -124,10 +177,12 @@ describe('Details Header', () => { ...@@ -124,10 +177,12 @@ describe('Details Header', () => {
'when the status is $status the text is $text and the tooltip is $tooltip', 'when the status is $status the text is $text and the tooltip is $tooltip',
async ({ status, text, tooltip }) => { async ({ status, text, tooltip }) => {
mountComponent({ mountComponent({
...defaultImage, image: {
expirationPolicyCleanupStatus: status, ...defaultImage,
project: { expirationPolicyCleanupStatus: status,
containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, project: {
containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' },
},
}, },
}); });
await waitForMetadataItems(); await waitForMetadataItems();
...@@ -156,7 +211,7 @@ describe('Details Header', () => { ...@@ -156,7 +211,7 @@ describe('Details Header', () => {
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye');
}); });
it('shows an eye slashed when the project is not public', async () => { it('shows an eye slashed when the project is not public', async () => {
mountComponent({ ...defaultImage, project: { visibility: 'private' } }); mountComponent({ image: { ...defaultImage, project: { visibility: 'private' } } });
await waitForMetadataItems(); await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash');
......
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/status_alert.vue';
import {
DELETE_SCHEDULED,
DELETE_FAILED,
PACKAGE_DELETE_HELP_PAGE_PATH,
SCHEDULED_FOR_DELETION_STATUS_TITLE,
SCHEDULED_FOR_DELETION_STATUS_MESSAGE,
FAILED_DELETION_STATUS_TITLE,
FAILED_DELETION_STATUS_MESSAGE,
} from '~/registry/explorer/constants';
describe('Status Alert', () => {
let wrapper;
const findLink = () => wrapper.find(GlLink);
const findAlert = () => wrapper.find(GlAlert);
const findMessage = () => wrapper.find('[data-testid="message"]');
const mountComponent = (propsData) => {
wrapper = shallowMount(component, {
propsData,
stubs: {
GlSprintf,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it.each`
status | title | variant | message | link
${DELETE_SCHEDULED} | ${SCHEDULED_FOR_DELETION_STATUS_TITLE} | ${'info'} | ${SCHEDULED_FOR_DELETION_STATUS_MESSAGE} | ${PACKAGE_DELETE_HELP_PAGE_PATH}
${DELETE_FAILED} | ${FAILED_DELETION_STATUS_TITLE} | ${'warning'} | ${FAILED_DELETION_STATUS_MESSAGE} | ${''}
`(
`when the status is $status, title is $title, variant is $variant, message is $message and the link is $link`,
({ status, title, variant, message, link }) => {
mountComponent({ status });
expect(findMessage().text()).toMatchInterpolatedText(message);
expect(findAlert().props()).toMatchObject({
title,
variant,
});
if (link) {
expect(findLink().attributes()).toMatchObject({
target: '_blank',
href: link,
});
}
},
);
});
...@@ -70,18 +70,25 @@ describe('Tags List', () => { ...@@ -70,18 +70,25 @@ describe('Tags List', () => {
}); });
}); });
it('is disabled when no item is selected', () => { it.each`
mountComponent(); disabled | doSelect | buttonDisabled
${true} | ${false} | ${'true'}
${true} | ${true} | ${'true'}
${false} | ${false} | ${'true'}
${false} | ${true} | ${undefined}
`(
'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag',
async ({ disabled, buttonDisabled, doSelect }) => {
mountComponent({ tags, disabled, isMobile: false });
expect(findDeleteButton().attributes('disabled')).toBe('true'); if (doSelect) {
}); findTagsListRow().at(0).vm.$emit('select');
await wrapper.vm.$nextTick();
}
it('is enabled when at least one item is selected', async () => { expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled);
mountComponent(); },
findTagsListRow().at(0).vm.$emit('select'); );
await wrapper.vm.$nextTick();
expect(findDeleteButton().attributes('disabled')).toBe(undefined);
});
it('click event emits a deleted event with selected items', () => { it('click event emits a deleted event with selected items', () => {
mountComponent(); mountComponent();
...@@ -100,12 +107,13 @@ describe('Tags List', () => { ...@@ -100,12 +107,13 @@ describe('Tags List', () => {
}); });
it('the correct props are bound to it', () => { it('the correct props are bound to it', () => {
mountComponent(); mountComponent({ tags, disabled: true });
const rows = findTagsListRow(); const rows = findTagsListRow();
expect(rows.at(0).attributes()).toMatchObject({ expect(rows.at(0).attributes()).toMatchObject({
first: 'true', first: 'true',
disabled: 'true',
}); });
}); });
......
...@@ -12,11 +12,17 @@ import DetailsHeader from '~/registry/explorer/components/details_page/details_h ...@@ -12,11 +12,17 @@ import DetailsHeader from '~/registry/explorer/components/details_page/details_h
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import TagsList from '~/registry/explorer/components/details_page/tags_list.vue'; import TagsList from '~/registry/explorer/components/details_page/tags_list.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_state.vue'; import EmptyTagsState from '~/registry/explorer/components/details_page/empty_state.vue';
import StatusAlert from '~/registry/explorer/components/details_page/status_alert.vue';
import DeleteImage from '~/registry/explorer/components/delete_image.vue';
import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
import { UNFINISHED_STATUS } from '~/registry/explorer/constants/index'; import {
UNFINISHED_STATUS,
DELETE_SCHEDULED,
ALERT_DANGER_IMAGE,
} from '~/registry/explorer/constants';
import { import {
graphQLImageDetailsMock, graphQLImageDetailsMock,
...@@ -43,6 +49,8 @@ describe('Details Page', () => { ...@@ -43,6 +49,8 @@ describe('Details Page', () => {
const findDetailsHeader = () => wrapper.find(DetailsHeader); const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyState = () => wrapper.find(EmptyTagsState); const findEmptyState = () => wrapper.find(EmptyTagsState);
const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
const findStatusAlert = () => wrapper.find(StatusAlert);
const findDeleteImage = () => wrapper.find(DeleteImage);
const routeId = 1; const routeId = 1;
...@@ -88,6 +96,7 @@ describe('Details Page', () => { ...@@ -88,6 +96,7 @@ describe('Details Page', () => {
apolloProvider, apolloProvider,
stubs: { stubs: {
DeleteModal, DeleteModal,
DeleteImage,
}, },
mocks: { mocks: {
$route: { $route: {
...@@ -507,4 +516,83 @@ describe('Details Page', () => { ...@@ -507,4 +516,83 @@ describe('Details Page', () => {
expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name);
}); });
}); });
describe('when the image has a status different from null', () => {
const resolver = jest
.fn()
.mockResolvedValue(graphQLImageDetailsMock({ status: DELETE_SCHEDULED }));
it('disables all the actions', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findDetailsHeader().props('disabled')).toBe(true);
expect(findTagsList().props('disabled')).toBe(true);
});
it('shows a status alert', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findStatusAlert().exists()).toBe(true);
expect(findStatusAlert().props()).toMatchObject({
status: DELETE_SCHEDULED,
});
});
});
describe('delete the image', () => {
const mountComponentAndDeleteImage = async () => {
mountComponent();
await waitForApolloRequestRender();
findDetailsHeader().vm.$emit('delete');
await wrapper.vm.$nextTick();
};
it('on delete event it deletes the image', async () => {
await mountComponentAndDeleteImage();
findDeleteModal().vm.$emit('confirmDelete');
expect(findDeleteImage().emitted('start')).toEqual([[]]);
});
it('binds the correct props to the modal', async () => {
await mountComponentAndDeleteImage();
expect(findDeleteModal().props()).toMatchObject({
itemsToBeDeleted: [{ path: 'gitlab-org/gitlab-test/rails-12009' }],
deleteImage: true,
});
});
it('binds correctly to delete-image start and end events', async () => {
mountComponent();
findDeleteImage().vm.$emit('start');
await wrapper.vm.$nextTick();
expect(findTagsLoader().exists()).toBe(true);
findDeleteImage().vm.$emit('end');
await wrapper.vm.$nextTick();
expect(findTagsLoader().exists()).toBe(false);
});
it('binds correctly to delete-image error event', async () => {
mountComponent();
findDeleteImage().vm.$emit('error');
await wrapper.vm.$nextTick();
expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE);
});
});
}); });
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