Commit ea2ce3eb authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '276432-refactor-container-registry-frontend-to-graphql' into 'master'

Refactor container registry details page to GraphQL

See merge request gitlab-org/gitlab!49342
parents bcdf6514 2361e6ae
......@@ -34,7 +34,7 @@ export default {
return this.tags.some(tag => this.selectedItems[tag.name]);
},
showMultiDeleteButton() {
return this.tags.some(tag => tag.destroy_path) && !this.isMobile;
return this.tags.some(tag => tag.canDelete) && !this.isMobile;
},
},
methods: {
......
......@@ -63,7 +63,7 @@ export default {
},
computed: {
formattedSize() {
return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE;
return this.tag.totalSize ? numberToHumanSize(this.tag.totalSize) : NOT_AVAILABLE_SIZE;
},
layers() {
return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : '';
......@@ -76,10 +76,10 @@ export default {
return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT;
},
publishedDate() {
return formatDate(this.tag.created_at, 'isoDate');
return formatDate(this.tag.createdAt, 'isoDate');
},
publishedTime() {
return formatDate(this.tag.created_at, 'hh:MM Z');
return formatDate(this.tag.createdAt, 'hh:MM Z');
},
formattedRevision() {
// to be removed when API response is adjusted
......@@ -101,7 +101,7 @@ export default {
<list-item v-bind="$attrs" :selected="selected">
<template #left-action>
<gl-form-checkbox
v-if="Boolean(tag.destroy_path)"
v-if="tag.canDelete"
:disabled="invalidTag"
class="gl-m-0"
:checked="selected"
......@@ -148,7 +148,7 @@ export default {
<span data-testid="time">
<gl-sprintf :message="$options.i18n.CREATED_AT_LABEL">
<template #timeInfo>
<time-ago-tooltip :time="tag.created_at" />
<time-ago-tooltip :time="tag.createdAt" />
</template>
</gl-sprintf>
</span>
......@@ -162,10 +162,10 @@ export default {
</template>
<template #right-action>
<delete-button
:disabled="!tag.destroy_path || invalidTag"
:disabled="!tag.canDelete || invalidTag"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
:tooltip-disabled="Boolean(tag.destroy_path)"
:tooltip-disabled="tag.canDelete"
data-testid="single-delete-button"
@delete="$emit('delete')"
/>
......
<script>
/* eslint-disable vue/no-v-html */
// We are forced to use `v-html` untill this gitlab-ui MR is merged: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1869
// We are forced to use `v-html` untill this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079
// then we can re-write this to use gl-breadcrumb
import { initial, first, last } from 'lodash';
import { sanitize } from '~/lib/dompurify';
......@@ -35,7 +35,7 @@ export default {
return {
tagName,
className,
text: this.$route.meta.nameGenerator(this.$store.state),
text: this.$route.meta.nameGenerator(),
path: { to: this.$route.name },
};
},
......@@ -53,7 +53,7 @@ export default {
></li>
<li v-if="!isRootRoute">
<router-link ref="rootRouteLink" :to="rootRoute.path">
{{ rootRoute.meta.nameGenerator($store.state) }}
{{ rootRoute.meta.nameGenerator() }}
</router-link>
<component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" />
</li>
......
mutation destroyContainerRepositoryTags($id: ContainerRepositoryID!, $tagNames: [String!]!) {
destroyContainerRepositoryTags(input: { id: $id, tagNames: $tagNames }) {
errors
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getContainerRepositoryDetails(
$id: ID!
$first: Int
$last: Int
$after: String
$before: String
) {
containerRepository(id: $id) {
id
name
path
status
location
canDelete
createdAt
tagsCount
expirationPolicyStartedAt
tags(after: $after, before: $before, first: $first, last: $last) {
nodes {
digest
location
path
name
revision
shortRevision
createdAt
totalSize
canDelete
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -19,8 +19,16 @@ export default () => {
const { endpoint } = el.dataset;
// This is a mini state to help the breadcrumb have the correct name in the details page
const breadCrumbState = Vue.observable({
name: '',
updateName(value) {
this.name = value;
},
});
const store = createStore();
const router = createRouter(endpoint);
const router = createRouter(endpoint, breadCrumbState);
store.dispatch('setInitialState', el.dataset);
const attachMainComponent = () =>
......@@ -32,6 +40,9 @@ export default () => {
components: {
RegistryExplorer,
},
provide() {
return { breadCrumbState };
},
render(createElement) {
return createElement('registry-explorer');
},
......@@ -42,8 +53,8 @@ export default () => {
const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')];
return new Vue({
el: breadCrumbEl,
store,
router,
apolloProvider,
components: {
RegistryBreadcrumb,
},
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { mapState } from 'vuex';
import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import createFlash from '~/flash';
import Tracking from '~/tracking';
import { joinPaths } from '~/lib/utils/url_utility';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
......@@ -11,11 +13,16 @@ import TagsList from '../components/details_page/tags_list.vue';
import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.graphql';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.graphql';
import {
ALERT_SUCCESS_TAG,
ALERT_DANGER_TAG,
ALERT_SUCCESS_TAGS,
ALERT_DANGER_TAGS,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../constants/index';
export default {
......@@ -23,28 +30,62 @@ export default {
DeleteAlert,
PartialCleanupAlert,
DetailsHeader,
GlPagination,
GlKeysetPagination,
DeleteModal,
TagsList,
TagsLoader,
EmptyTagsState,
},
inject: ['breadCrumbState'],
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [Tracking.mixin()],
apollo: {
image: {
query: getContainerRepositoryDetailsQuery,
variables() {
return this.queryVariables;
},
update(data) {
return data.containerRepository;
},
result({ data }) {
this.tagsPageInfo = data.containerRepository?.tags?.pageInfo;
this.breadCrumbState.updateName(data.containerRepository?.name);
},
error() {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
},
},
},
data() {
return {
image: {},
tagsPageInfo: {},
itemsToBeDeleted: [],
isMobile: false,
mutationLoading: false,
deleteAlertType: null,
dismissPartialCleanupWarning: false,
};
},
computed: {
...mapState(['tagsPagination', 'isLoading', 'config', 'tags', 'imageDetails']),
...mapState(['config']),
queryVariables() {
return {
id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`),
first: GRAPHQL_PAGE_SIZE,
};
},
isLoading() {
return this.$apollo.queries.image.loading || this.mutationLoading;
},
tags() {
return this.image?.tags?.nodes || [];
},
showPartialCleanupWarning() {
return this.imageDetails?.cleanup_policy_started_at && !this.dismissPartialCleanupWarning;
return this.image?.expirationPolicyStartedAt && !this.dismissPartialCleanupWarning;
},
tracking() {
return {
......@@ -52,66 +93,78 @@ export default {
this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
currentPage: {
get() {
return this.tagsPagination.page;
},
set(page) {
this.requestTagsList({ page });
},
showPagination() {
return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
},
},
mounted() {
this.requestImageDetailsAndTagsList(this.$route.params.id);
},
methods: {
...mapActions([
'requestTagsList',
'requestDeleteTag',
'requestDeleteTags',
'requestImageDetailsAndTagsList',
]),
deleteTags(toBeDeleted) {
this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]);
this.track('click_button');
this.$refs.deleteModal.show();
},
handleSingleDelete() {
const [itemToDelete] = this.itemsToBeDeleted;
this.itemsToBeDeleted = [];
return this.requestDeleteTag({ tag: itemToDelete })
.then(() => {
this.deleteAlertType = ALERT_SUCCESS_TAG;
})
.catch(() => {
this.deleteAlertType = ALERT_DANGER_TAG;
});
},
handleMultipleDelete() {
async handleDelete() {
this.track('confirm_delete');
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
return this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => x.name),
})
.then(() => {
this.deleteAlertType = ALERT_SUCCESS_TAGS;
})
.catch(() => {
this.deleteAlertType = ALERT_DANGER_TAGS;
this.mutationLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: deleteContainerRepositoryTagsMutation,
variables: {
id: this.queryVariables.id,
tagNames: itemsToBeDeleted.map(i => i.name),
},
awaitRefetchQueries: true,
refetchQueries: [
{
query: getContainerRepositoryDetailsQuery,
variables: this.queryVariables,
},
],
});
},
onDeletionConfirmed() {
this.track('confirm_delete');
if (this.itemsToBeDeleted.length > 1) {
this.handleMultipleDelete();
} else {
this.handleSingleDelete();
if (data?.destroyContainerRepositoryTags?.errors[0]) {
throw new Error();
}
this.deleteAlertType =
itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
} catch (e) {
this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
}
this.mutationLoading = false;
},
handleResize() {
this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
},
fetchNextPage() {
if (this.tagsPageInfo?.hasNextPage) {
this.$apollo.queries.image.fetchMore({
variables: {
after: this.tagsPageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
fetchPreviousPage() {
if (this.tagsPageInfo?.hasPreviousPage) {
this.$apollo.queries.image.fetchMore({
variables: {
first: null,
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
},
};
</script>
......@@ -132,28 +185,30 @@ export default {
@dismiss="dismissPartialCleanupWarning = true"
/>
<details-header :image-name="imageDetails.name" />
<details-header :image-name="image.name" />
<tags-loader v-if="isLoading" />
<template v-else>
<empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" />
<tags-list v-else :tags="tags" :is-mobile="isMobile" @delete="deleteTags" />
<template v-else>
<tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" />
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
:has-next-page="tagsPageInfo.hasNextPage"
:has-previous-page="tagsPageInfo.hasPreviousPage"
class="gl-mt-3"
@prev="fetchPreviousPage"
@next="fetchNextPage"
/>
</div>
</template>
</template>
<gl-pagination
v-if="!isLoading"
ref="pagination"
v-model="currentPage"
:per-page="tagsPagination.perPage"
:total-items="tagsPagination.total"
align="center"
class="gl-w-full gl-mt-3"
/>
<delete-modal
ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted"
@confirmDelete="onDeletionConfirmed"
@confirmDelete="handleDelete"
@cancel="track('cancel_delete')"
/>
</div>
......
......@@ -6,7 +6,7 @@ import { CONTAINER_REGISTRY_TITLE } from './constants/index';
Vue.use(VueRouter);
export default function createRouter(base) {
export default function createRouter(base, breadCrumbState) {
const router = new VueRouter({
base,
mode: 'history',
......@@ -25,7 +25,7 @@ export default function createRouter(base) {
path: '/:id',
component: Details,
meta: {
nameGenerator: ({ imageDetails }) => imageDetails?.name,
nameGenerator: () => breadCrumbState.name,
},
},
],
......
......@@ -5,4 +5,8 @@ module ContainerRegistryHelper
Feature.enabled?(:container_registry_expiration_policies_throttling) &&
ContainerRegistry::Client.supports_tag_delete?
end
def container_repository_gid_prefix
"gid://#{GlobalID.app}/#{ContainerRepository.name}/"
end
end
......@@ -17,4 +17,5 @@
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
"group_path": @group.full_path,
"gid_prefix": container_repository_gid_prefix,
character_error: @character_error.to_s } }
......@@ -18,5 +18,6 @@
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"project_path": @project.full_path,
"gid_prefix": container_repository_gid_prefix,
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
......@@ -94,7 +94,7 @@ RSpec.describe 'Container Registry', :js do
end
it('pagination navigate to the second page') do
visit_details_second_page
visit_next_page
expect(page).to have_content '20'
end
......@@ -128,12 +128,12 @@ RSpec.describe 'Container Registry', :js do
end
it 'pagination goes to second page' do
visit_list_next_page
visit_next_page
expect(page).to have_content 'my/image'
end
it 'pagination is preserved after navigating back from details' do
visit_list_next_page
visit_next_page
click_link 'my/image'
breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry'
......@@ -150,13 +150,8 @@ RSpec.describe 'Container Registry', :js do
click_link name
end
def visit_list_next_page
def visit_next_page
pagination = find '.gl-keyset-pagination'
pagination.click_button 'Next'
end
def visit_details_second_page
pagination = find '.gl-pagination'
pagination.click_link '2'
end
end
......@@ -20,7 +20,7 @@ import { ListItem } from '../../stubs';
describe('tags list row', () => {
let wrapper;
const [tag] = [...tagsListResponse.data];
const [tag] = [...tagsListResponse];
const defaultProps = { tag, isMobile: false, index: 0 };
......@@ -65,7 +65,7 @@ describe('tags list row', () => {
});
it("does not exist when the row can't be deleted", () => {
const customTag = { ...tag, destroy_path: '' };
const customTag = { ...tag, canDelete: false };
mountComponent({ ...defaultProps, tag: customTag });
......@@ -137,8 +137,8 @@ describe('tags list row', () => {
mountComponent();
expect(findClipboardButton().attributes()).toMatchObject({
text: 'location',
title: 'location',
text: tag.location,
title: tag.location,
});
});
});
......@@ -171,26 +171,26 @@ describe('tags list row', () => {
expect(findSize().exists()).toBe(true);
});
it('contains the total_size and layers', () => {
mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024 } });
it('contains the totalSize and layers', () => {
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024 } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers');
});
it('when total_size is missing', () => {
mountComponent();
it('when totalSize is missing', () => {
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 0 } });
expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`);
});
it('when layers are missing', () => {
mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024, layers: null } });
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024, layers: null } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB');
});
it('when there is 1 layer', () => {
mountComponent({ ...defaultProps, tag: { ...tag, layers: 1 } });
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 0, layers: 1 } });
expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 1 layer`);
});
......@@ -218,7 +218,7 @@ describe('tags list row', () => {
it('pass the correct props to time ago tooltip', () => {
mountComponent();
expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.created_at });
expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.createdAt });
});
});
......@@ -232,7 +232,7 @@ describe('tags list row', () => {
it('has the correct text', () => {
mountComponent();
expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 1ab51d5');
expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 9d72ae1');
});
it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => {
......@@ -260,18 +260,15 @@ describe('tags list row', () => {
});
it.each`
destroy_path | digest
${'foo'} | ${null}
${null} | ${'foo'}
${null} | ${null}
`(
'is disabled when destroy_path is $destroy_path and digest is $digest',
({ destroy_path, digest }) => {
mountComponent({ ...defaultProps, tag: { ...tag, destroy_path, digest } });
expect(findDeleteButton().attributes('disabled')).toBe('true');
},
);
canDelete | digest
${true} | ${null}
${false} | ${'foo'}
${false} | ${null}
`('is disabled when canDelete is $canDelete and digest is $digest', ({ canDelete, digest }) => {
mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest } });
expect(findDeleteButton().attributes('disabled')).toBe('true');
});
it('delete event emits delete', () => {
mountComponent();
......@@ -295,10 +292,10 @@ describe('tags list row', () => {
});
describe.each`
name | finderFunction | text | icon | clipboard
${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the bar image repository at 10:23 GMT+0000 on 2020-06-29'} | ${'clock'} | ${false}
${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c'} | ${'log'} | ${true}
${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43'} | ${'cloud-gear'} | ${true}
name | finderFunction | text | icon | clipboard
${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 GMT+0000 on 2020-11-03'} | ${'clock'} | ${false}
${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:9d72ae1db47404e44e1760eb1ca4cb427b84be8c511f05dfe2089e1b9f741dd7'} | ${'log'} | ${true}
${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:5183b5d133fa864dca2de602f874b0d1bffe0f204ad894e3660432a487935139'} | ${'cloud-gear'} | ${true}
`('$name details row', ({ finderFunction, text, icon, clipboard }) => {
it(`has ${text} as text`, () => {
expect(finderFunction().text()).toMatchInterpolatedText(text);
......
......@@ -7,8 +7,8 @@ import { tagsListResponse } from '../../mock_data';
describe('Tags List', () => {
let wrapper;
const tags = [...tagsListResponse.data];
const readOnlyTags = tags.map(t => ({ ...t, destroy_path: undefined }));
const tags = [...tagsListResponse];
const readOnlyTags = tags.map(t => ({ ...t, canDelete: false }));
const findTagsListRow = () => wrapper.findAll(TagsListRow);
const findDeleteButton = () => wrapper.find(GlButton);
......@@ -92,7 +92,7 @@ describe('Tags List', () => {
.vm.$emit('select');
findDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]);
expect(wrapper.emitted('delete')).toEqual([[{ 'alpha-11821': true }]]);
});
});
......@@ -132,7 +132,7 @@ describe('Tags List', () => {
findTagsListRow()
.at(0)
.vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]);
expect(wrapper.emitted('delete')).toEqual([[{ 'alpha-11821': true }]]);
});
});
});
......
......@@ -32,10 +32,6 @@ describe('Registry Breadcrumb', () => {
{ name: 'baz', meta: { nameGenerator } },
];
const state = {
imageDetails: { foo: 'bar' },
};
const findDivider = () => wrapper.find('.js-divider');
const findRootRoute = () => wrapper.find({ ref: 'rootRouteLink' });
const findChildRoute = () => wrapper.find({ ref: 'childRouteLink' });
......@@ -56,9 +52,6 @@ describe('Registry Breadcrumb', () => {
routes,
},
},
$store: {
state,
},
},
});
};
......@@ -87,7 +80,6 @@ describe('Registry Breadcrumb', () => {
});
it('the link text is calculated by nameGenerator', () => {
expect(nameGenerator).toHaveBeenCalledWith(state);
expect(nameGenerator).toHaveBeenCalledTimes(1);
});
});
......@@ -111,7 +103,6 @@ describe('Registry Breadcrumb', () => {
});
it('the link text is calculated by nameGenerator', () => {
expect(nameGenerator).toHaveBeenCalledWith(state);
expect(nameGenerator).toHaveBeenCalledTimes(2);
});
});
......
......@@ -72,34 +72,34 @@ export const imagesListResponse = [
},
];
export const tagsListResponse = {
data: [
{
name: 'centos6',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
short_revision: 'b118ab5b0',
size: 19,
layers: 10,
location: 'location',
path: 'bar:centos6',
created_at: '2020-06-29T10:23:51.766+00:00',
destroy_path: 'path',
digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c',
},
{
name: 'test-tag',
revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4',
short_revision: 'b969de599',
size: 19,
layers: 10,
path: 'foo:test-tag',
location: 'location-2',
created_at: '2020-06-29T10:23:51.766+00:00',
digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7736dfd5c',
},
],
headers,
};
export const tagsListResponse = [
{
canDelete: true,
createdAt: '2020-11-03T13:29:49+00:00',
digest: 'sha256:9d72ae1db47404e44e1760eb1ca4cb427b84be8c511f05dfe2089e1b9f741dd7',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:alpha-11821',
name: 'alpha-11821',
path: 'gitlab-org/gitlab-test/rails-12009:alpha-11821',
revision: '5183b5d133fa864dca2de602f874b0d1bffe0f204ad894e3660432a487935139',
shortRevision: '5183b5d13',
totalSize: 104,
layers: 10,
__typename: 'ContainerRepositoryTag',
},
{
canDelete: true,
createdAt: '2020-11-03T13:29:48+00:00',
digest: 'sha256:64f61282a71659f72066f9decd30b9038a465859b277a5e20da8681eb83e72f7',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:alpha-20825',
name: 'alpha-20825',
path: 'gitlab-org/gitlab-test/rails-12009:alpha-20825',
revision: 'e4212f1b73c6f9def2c37fa7df6c8d35c345fb1402860ff9a56404821aacf16f',
shortRevision: 'e4212f1b7',
totalSize: 105,
layers: 10,
__typename: 'ContainerRepositoryTag',
},
];
export const pageInfo = {
hasNextPage: true,
......@@ -110,14 +110,14 @@ export const pageInfo = {
};
export const imageDetailsMock = {
id: 1,
name: 'rails-32309',
path: 'gitlab-org/gitlab-test/rails-32309',
project_id: 1,
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-32309',
created_at: '2020-06-29T10:23:47.838Z',
cleanup_policy_started_at: null,
delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
expirationPolicyStartedAt: null,
id: 'gid://gitlab/ContainerRepository/26',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009',
name: 'rails-12009',
path: 'gitlab-org/gitlab-test/rails-12009',
status: null,
};
export const graphQLImageListMock = {
......@@ -192,3 +192,96 @@ export const graphQLImageDeleteMockError = {
},
},
};
export const containerRepositoryMock = {
id: 'gid://gitlab/ContainerRepository/26',
name: 'rails-12009',
path: 'gitlab-org/gitlab-test/rails-12009',
status: null,
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
tagsCount: 13,
expirationPolicyStartedAt: null,
};
export const tagsPageInfo = {
__typename: 'PageInfo',
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'MQ',
endCursor: 'MTA',
};
export const tagsMock = [
{
digest: 'sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-24753',
path: 'gitlab-org/gitlab-test/rails-12009:beta-24753',
name: 'beta-24753',
revision: 'c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b',
shortRevision: 'c2613843a',
createdAt: '2020-11-03T13:29:38+00:00',
totalSize: 105,
canDelete: true,
__typename: 'ContainerRepositoryTag',
},
{
digest: 'sha256:7f94f97dff89ffd122cafe50cd32329adf682356a7a96f69cbfe313ee589791c',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-31075',
path: 'gitlab-org/gitlab-test/rails-12009:beta-31075',
name: 'beta-31075',
revision: 'df44e7228f0f255c73e35b6f0699624a615f42746e3e8e2e4b3804a6d6fc3292',
shortRevision: 'df44e7228',
createdAt: '2020-11-03T13:29:32+00:00',
totalSize: 104,
canDelete: true,
__typename: 'ContainerRepositoryTag',
},
];
export const graphQLImageDetailsMock = override => ({
data: {
containerRepository: {
...containerRepositoryMock,
tags: {
nodes: tagsMock,
pageInfo: { ...tagsPageInfo },
__typename: 'ContainerRepositoryTagConnection',
},
__typename: 'ContainerRepositoryDetails',
...override,
},
},
});
export const graphQLImageDetailsEmptyTagsMock = {
data: {
containerRepository: {
...containerRepositoryMock,
tags: {
nodes: [],
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
__typename: 'ContainerRepositoryTagConnection',
},
__typename: 'ContainerRepositoryDetails',
},
},
};
export const graphQLDeleteImageRepositoryTagsMock = {
data: {
destroyContainerRepositoryTags: {
deletedTagNames: [],
errors: [],
__typename: 'DestroyContainerRepositoryTagsPayload',
},
},
};
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlKeysetPagination } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/details.vue';
import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue';
......@@ -9,24 +12,29 @@ import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.
import TagsList from '~/registry/explorer/components/details_page/tags_list.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import { createStore } from '~/registry/explorer/stores/';
import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.graphql';
import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.graphql';
import {
SET_MAIN_LOADING,
SET_TAGS_LIST_SUCCESS,
SET_TAGS_PAGINATION,
SET_INITIAL_STATE,
SET_IMAGE_DETAILS,
} from '~/registry/explorer/stores/mutation_types';
import { tagsListResponse, imageDetailsMock } from '../mock_data';
graphQLImageDetailsMock,
graphQLImageDetailsEmptyTagsMock,
graphQLDeleteImageRepositoryTagsMock,
containerRepositoryMock,
tagsMock,
tagsPageInfo,
} from '../mock_data';
import { DeleteModal } from '../stubs';
const localVue = createLocalVue();
describe('Details Page', () => {
let wrapper;
let dispatchSpy;
let store;
let apolloProvider;
const findDeleteModal = () => wrapper.find(DeleteModal);
const findPagination = () => wrapper.find(GlPagination);
const findPagination = () => wrapper.find(GlKeysetPagination);
const findTagsLoader = () => wrapper.find(TagsLoader);
const findTagsList = () => wrapper.find(TagsList);
const findDeleteAlert = () => wrapper.find(DeleteAlert);
......@@ -36,15 +44,46 @@ describe('Details Page', () => {
const routeId = 1;
const breadCrumbState = {
updateName: jest.fn(),
};
const cleanTags = tagsMock.map(t => {
const result = { ...t };
// eslint-disable-next-line no-underscore-dangle
delete result.__typename;
return result;
});
const waitForApolloRequestRender = async () => {
await waitForPromises();
await wrapper.vm.$nextTick();
};
const tagsArrayToSelectedTags = tags =>
tags.reduce((acc, c) => {
acc[c.name] = true;
return acc;
}, {});
const mountComponent = ({ options } = {}) => {
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()),
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
options,
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [
[getContainerRepositoryDetailsQuery, resolver],
[deleteContainerRepositoryTagsMutation, mutationResolver],
];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(component, {
store,
localVue,
apolloProvider,
stubs: {
DeleteModal,
},
......@@ -55,17 +94,17 @@ describe('Details Page', () => {
},
},
},
provide() {
return {
breadCrumbState,
};
},
...options,
});
};
beforeEach(() => {
store = createStore();
dispatchSpy = jest.spyOn(store, 'dispatch');
dispatchSpy.mockResolvedValue();
store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data);
store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers);
store.commit(SET_IMAGE_DETAILS, imageDetailsMock);
jest.spyOn(Tracking, 'event');
});
......@@ -74,85 +113,90 @@ describe('Details Page', () => {
wrapper = null;
});
describe('lifecycle events', () => {
it('calls the appropriate action on mount', () => {
mountComponent();
expect(dispatchSpy).toHaveBeenCalledWith('requestImageDetailsAndTagsList', routeId);
});
});
describe('when isLoading is true', () => {
beforeEach(() => {
store.commit(SET_MAIN_LOADING, true);
it('shows the loader', () => {
mountComponent();
});
afterEach(() => store.commit(SET_MAIN_LOADING, false));
it('shows the loader', () => {
expect(findTagsLoader().exists()).toBe(true);
});
it('does not show the list', () => {
mountComponent();
expect(findTagsList().exists()).toBe(false);
});
it('does not show pagination', () => {
mountComponent();
expect(findPagination().exists()).toBe(false);
});
});
describe('when the list of tags is empty', () => {
beforeEach(() => {
store.commit(SET_TAGS_LIST_SUCCESS, []);
mountComponent();
});
const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock);
it('has the empty state', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
it('has the empty state', () => {
expect(findEmptyTagsState().exists()).toBe(true);
});
it('does not show the loader', () => {
it('does not show the loader', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findTagsLoader().exists()).toBe(false);
});
it('does not show the list', () => {
it('does not show the list', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findTagsList().exists()).toBe(false);
});
});
describe('list', () => {
beforeEach(() => {
it('exists', async () => {
mountComponent();
});
it('exists', () => {
await waitForApolloRequestRender();
expect(findTagsList().exists()).toBe(true);
});
it('has the correct props bound', () => {
it('has the correct props bound', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findTagsList().props()).toMatchObject({
isMobile: false,
tags: store.state.tags,
tags: cleanTags,
});
});
describe('deleteEvent', () => {
describe('single item', () => {
let tagToBeDeleted;
beforeEach(() => {
[tagToBeDeleted] = store.state.tags;
beforeEach(async () => {
mountComponent();
await waitForApolloRequestRender();
[tagToBeDeleted] = cleanTags;
findTagsList().vm.$emit('delete', { [tagToBeDeleted.name]: true });
});
it('open the modal', () => {
it('open the modal', async () => {
expect(DeleteModal.methods.show).toHaveBeenCalled();
});
it('maps the selection to itemToBeDeleted', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([tagToBeDeleted]);
});
it('tracks a single delete event', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
......@@ -161,18 +205,18 @@ describe('Details Page', () => {
});
describe('multiple items', () => {
beforeEach(() => {
findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags));
beforeEach(async () => {
mountComponent();
await waitForApolloRequestRender();
findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(cleanTags));
});
it('open the modal', () => {
expect(DeleteModal.methods.show).toHaveBeenCalled();
});
it('maps the selection to itemToBeDeleted', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual(store.state.tags);
});
it('tracks a single delete event', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'bulk_registry_tag_delete',
......@@ -183,40 +227,77 @@ describe('Details Page', () => {
});
describe('pagination', () => {
beforeEach(() => {
it('exists', async () => {
mountComponent();
});
it('exists', () => {
await waitForApolloRequestRender();
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(store.state.tagsPagination.perPage);
expect(pagination.props('totalItems')).toBe(store.state.tagsPagination.total);
expect(pagination.props('value')).toBe(store.state.tagsPagination.page);
it('is hidden when there are no more pages', async () => {
mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock) });
await waitForApolloRequestRender();
expect(findPagination().exists()).toBe(false);
});
it('fetch the data from the API when the v-model changes', () => {
dispatchSpy.mockResolvedValue();
findPagination().vm.$emit(GlPagination.model.event, 2);
expect(store.dispatch).toHaveBeenCalledWith('requestTagsList', {
page: 2,
it('is wired to the correct pagination props', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findPagination().props()).toMatchObject({
hasNextPage: tagsPageInfo.hasNextPage,
hasPreviousPage: tagsPageInfo.hasPreviousPage,
});
});
it('fetch next page when user clicks next', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock());
mountComponent({ resolver });
await waitForApolloRequestRender();
findPagination().vm.$emit('next');
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: tagsPageInfo.endCursor }),
);
});
it('fetch previous page when user clicks prev', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock());
mountComponent({ resolver });
await waitForApolloRequestRender();
findPagination().vm.$emit('prev');
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }),
);
});
});
describe('modal', () => {
it('exists', () => {
it('exists', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findDeleteModal().exists()).toBe(true);
});
describe('cancel event', () => {
it('tracks cancel_delete', () => {
it('tracks cancel_delete', async () => {
mountComponent();
await waitForApolloRequestRender();
findDeleteModal().vm.$emit('cancel');
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
label: 'registry_tag_delete',
});
......@@ -224,45 +305,57 @@ describe('Details Page', () => {
});
describe('confirmDelete event', () => {
let mutationResolver;
beforeEach(() => {
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
mountComponent({ mutationResolver });
return waitForApolloRequestRender();
});
describe('when one item is selected to be deleted', () => {
beforeEach(() => {
mountComponent();
findTagsList().vm.$emit('delete', { [store.state.tags[0].name]: true });
});
it('calls apollo mutation with the right parameters', async () => {
findTagsList().vm.$emit('delete', { [cleanTags[0].name]: true });
await wrapper.vm.$nextTick();
it('dispatch requestDeleteTag with the right parameters', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0],
});
expect(mutationResolver).toHaveBeenCalledWith(
expect.objectContaining({ tagNames: [cleanTags[0].name] }),
);
});
});
describe('when more than one item is selected to be deleted', () => {
beforeEach(() => {
mountComponent();
findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags));
});
it('calls apollo mutation with the right parameters', async () => {
findTagsList().vm.$emit('delete', { ...tagsArrayToSelectedTags(tagsMock) });
await wrapper.vm.$nextTick();
it('dispatch requestDeleteTags with the right parameters', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name),
});
expect(mutationResolver).toHaveBeenCalledWith(
expect.objectContaining({ tagNames: tagsMock.map(t => t.name) }),
);
});
});
});
});
describe('Header', () => {
it('exists', () => {
it('exists', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findDetailsHeader().exists()).toBe(true);
});
it('has the correct props', () => {
it('has the correct props', async () => {
mountComponent();
expect(findDetailsHeader().props()).toEqual({ imageName: imageDetailsMock.name });
await waitForApolloRequestRender();
expect(findDetailsHeader().props()).toEqual({ imageName: containerRepositoryMock.name });
});
});
......@@ -273,13 +366,15 @@ describe('Details Page', () => {
};
const deleteAlertType = 'success_tag';
it('exists', () => {
it('exists', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findDeleteAlert().exists()).toBe(true);
});
it('has the correct props', () => {
store.commit(SET_INITIAL_STATE, { ...config });
it('has the correct props', async () => {
store.commit('SET_INITIAL_STATE', { ...config });
mountComponent({
options: {
data: () => ({
......@@ -287,6 +382,9 @@ describe('Details Page', () => {
}),
},
});
await waitForApolloRequestRender();
expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType });
});
});
......@@ -298,30 +396,40 @@ describe('Details Page', () => {
};
describe('when expiration_policy_started is not null', () => {
let resolver;
beforeEach(() => {
store.commit(SET_IMAGE_DETAILS, {
...imageDetailsMock,
cleanup_policy_started_at: Date.now().toString(),
});
resolver = jest.fn().mockResolvedValue(
graphQLImageDetailsMock({
expirationPolicyStartedAt: Date.now().toString(),
}),
);
});
it('exists', () => {
mountComponent();
it('exists', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findPartialCleanupAlert().exists()).toBe(true);
});
it('has the correct props', () => {
store.commit(SET_INITIAL_STATE, { ...config });
it('has the correct props', async () => {
store.commit('SET_INITIAL_STATE', { ...config });
mountComponent();
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findPartialCleanupAlert().props()).toEqual({ ...config });
});
it('dismiss hides the component', async () => {
mountComponent();
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findPartialCleanupAlert().exists()).toBe(true);
findPartialCleanupAlert().vm.$emit('dismiss');
await wrapper.vm.$nextTick();
......@@ -331,11 +439,22 @@ describe('Details Page', () => {
});
describe('when expiration_policy_started is null', () => {
it('the component is hidden', () => {
it('the component is hidden', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findPartialCleanupAlert().exists()).toBe(false);
});
});
});
describe('Breadcrumb connection', () => {
it('when the details are fetched updates the name', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name);
});
});
});
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