Commit 62b99717 authored by Natalia Tepluhina's avatar Natalia Tepluhina

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

Refactor container registry list page to grapqhl

See merge request gitlab-org/gitlab!48602
parents ce1a7c46 341637eb
<script>
import { GlPagination } from '@gitlab/ui';
import { GlKeysetPagination } from '@gitlab/ui';
import ImageListRow from './image_list_row.vue';
export default {
name: 'ImageList',
components: {
GlPagination,
GlKeysetPagination,
ImageListRow,
},
props: {
......@@ -13,19 +13,14 @@ export default {
type: Array,
required: true,
},
pagination: {
pageInfo: {
type: Object,
required: true,
},
},
computed: {
currentPage: {
get() {
return this.pagination.page;
},
set(page) {
this.$emit('pageChange', page);
},
showPagination() {
return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
},
},
};
......@@ -40,13 +35,15 @@ export default {
:first="index === 0"
@delete="$emit('delete', $event)"
/>
<gl-pagination
v-model="currentPage"
:per-page="pagination.perPage"
:total-items="pagination.total"
align="center"
class="w-100 gl-mt-3"
/>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
class="gl-mt-3"
@prev="$emit('prev-page')"
@next="$emit('next-page')"
/>
</div>
</div>
</template>
<script>
import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import DeleteButton from '../delete_button.vue';
......@@ -11,6 +13,8 @@ import {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
} from '../../constants/index';
export default {
......@@ -38,19 +42,29 @@ export default {
},
computed: {
disabledDelete() {
return !this.item.destroy_path || this.item.deleting;
return !this.item.canDelete || this.deleting;
},
id() {
return getIdFromGraphQLId(this.item.id);
},
deleting() {
return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS;
},
failedDelete() {
return this.item.status === IMAGE_FAILED_DELETED_STATUS;
},
tagsCountText() {
return n__(
'ContainerRegistry|%{count} Tag',
'ContainerRegistry|%{count} Tags',
this.item.tags_count,
this.item.tagsCount,
);
},
warningIconText() {
if (this.item.failedDelete) {
if (this.failedDelete) {
return ASYNC_DELETE_IMAGE_ERROR_MESSAGE;
} else if (this.item.cleanup_policy_started_at) {
}
if (this.item.expirationPolicyStartedAt) {
return CLEANUP_TIMED_OUT_ERROR_MESSAGE;
}
return null;
......@@ -63,23 +77,23 @@ export default {
<list-item
v-gl-tooltip="{
placement: 'left',
disabled: !item.deleting,
disabled: !deleting,
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
}"
v-bind="$attrs"
:disabled="item.deleting"
:disabled="deleting"
>
<template #left-primary>
<router-link
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
:to="{ name: 'details', params: { id: item.id } }"
:to="{ name: 'details', params: { id } }"
>
{{ item.path }}
</router-link>
<clipboard-button
v-if="item.location"
:disabled="item.deleting"
:disabled="deleting"
:text="item.location"
:title="item.location"
category="tertiary"
......@@ -97,7 +111,7 @@ export default {
<gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
{{ item.tags_count }}
{{ item.tagsCount }}
</template>
</gl-sprintf>
</span>
......@@ -106,7 +120,7 @@ export default {
<delete-button
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete"
:tooltip-disabled="Boolean(item.destroy_path)"
:tooltip-disabled="item.canDelete"
:tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
@delete="$emit('delete', item)"
/>
......
......@@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__(
// Parameters
export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
export const GRAPHQL_PAGE_SIZE = 10;
fragment ContainerRepositoryFields on ContainerRepository {
id
name
path
status
location
canDelete
createdAt
tagsCount
expirationPolicyStartedAt
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
assumeImmutableResults: true,
},
),
});
mutation destroyContainerRepository($id: ContainerRepositoryID!) {
destroyContainerRepository(input: { id: $id }) {
containerRepository {
id
status
}
errors
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/container_repository.fragment.graphql"
query getProjectContainerRepositories(
$fullPath: ID!
$name: String
$first: Int
$last: Int
$after: String
$before: String
) {
group(fullPath: $fullPath) {
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
nodes {
...ContainerRepositoryFields
}
pageInfo {
...PageInfo
}
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/container_repository.fragment.graphql"
query getProjectContainerRepositories(
$fullPath: ID!
$name: String
$first: Int
$last: Int
$after: String
$before: String
) {
project(fullPath: $fullPath) {
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
nodes {
...ContainerRepositoryFields
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -5,6 +5,7 @@ import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
import { createStore } from './stores';
import createRouter from './router';
import { apolloProvider } from './graphql/index';
Vue.use(Translate);
Vue.use(GlToast);
......@@ -27,6 +28,7 @@ export default () => {
el,
store,
router,
apolloProvider,
components: {
RegistryExplorer,
},
......
<script>
import { mapState, mapActions } from 'vuex';
import { mapState } from 'vuex';
import {
GlEmptyState,
GlTooltipDirective,
......@@ -11,6 +11,7 @@ import {
GlSearchBoxByClick,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import createFlash from '~/flash';
import ProjectEmptyState from '../components/list_page/project_empty_state.vue';
import GroupEmptyState from '../components/list_page/group_empty_state.vue';
......@@ -18,6 +19,10 @@ import RegistryHeader from '../components/list_page/registry_header.vue';
import ImageList from '../components/list_page/image_list.vue';
import CliCommands from '../components/list_page/cli_commands.vue';
import getProjectContainerRepositories from '../graphql/queries/get_project_container_repositories.graphql';
import getGroupContainerRepositories from '../graphql/queries/get_group_container_repositories.graphql';
import deleteContainerRepository from '../graphql/mutations/delete_container_repository.graphql';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
......@@ -29,6 +34,8 @@ import {
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../constants/index';
export default {
......@@ -66,21 +73,63 @@ export default {
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
},
apollo: {
images: {
query() {
return this.graphQlQuery;
},
variables() {
return this.queryVariables;
},
update(data) {
return data[this.graphqlResource]?.containerRepositories.nodes;
},
result({ data }) {
this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo;
this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount;
},
error() {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
},
},
},
data() {
return {
images: [],
pageInfo: {},
containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
search: null,
isEmpty: false,
searchValue: null,
name: null,
mutationLoading: false,
};
},
computed: {
...mapState(['config', 'isLoading', 'images', 'pagination']),
...mapState(['config']),
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
},
graphQlQuery() {
return this.config.isGroupPage
? getGroupContainerRepositories
: getProjectContainerRepositories;
},
queryVariables() {
return {
name: this.name,
fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
first: GRAPHQL_PAGE_SIZE,
};
},
tracking() {
return {
label: 'registry_repository_delete',
};
},
isLoading() {
return this.$apollo.queries.images.loading || this.mutationLoading;
},
showCommands() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
......@@ -93,19 +142,7 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
mounted() {
this.loadImageList(this.$route.name);
},
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
loadImageList(fromName) {
if (!fromName || !this.images?.length) {
return this.requestImagesList().then(() => {
this.isEmpty = this.images.length === 0;
});
}
return Promise.resolve();
},
deleteImage(item) {
this.track('click_button');
this.itemToDelete = item;
......@@ -113,18 +150,59 @@ export default {
},
handleDeleteImage() {
this.track('confirm_delete');
return this.requestDeleteImage(this.itemToDelete)
.then(() => {
this.deleteAlertType = 'success';
this.mutationLoading = true;
return this.$apollo
.mutate({
mutation: deleteContainerRepository,
variables: {
id: this.itemToDelete.id,
},
})
.then(({ data }) => {
if (data?.destroyContainerRepository?.errors[0]) {
this.deleteAlertType = 'danger';
} else {
this.deleteAlertType = 'success';
}
})
.catch(() => {
this.deleteAlertType = 'danger';
})
.finally(() => {
this.mutationLoading = false;
});
},
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
},
fetchNextPage() {
if (this.pageInfo?.hasNextPage) {
this.$apollo.queries.images.fetchMore({
variables: {
after: this.pageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
fetchPreviousPage() {
if (this.pageInfo?.hasPreviousPage) {
this.$apollo.queries.images.fetchMore({
variables: {
first: null,
before: this.pageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
},
};
</script>
......@@ -134,7 +212,7 @@ export default {
<gl-alert
v-if="showDeleteAlert"
:variant="deleteAlertType"
class="mt-2"
class="gl-mt-5"
dismissible
@dismiss="dismissDeleteAlert"
>
......@@ -165,7 +243,7 @@ export default {
<template v-else>
<registry-header
:images-count="pagination.total"
:images-count="containerRepositoriesCount"
:expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath"
:expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
......@@ -176,7 +254,7 @@ export default {
</template>
</registry-header>
<div v-if="isLoading" class="mt-2">
<div v-if="isLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
......@@ -190,16 +268,17 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
<template v-if="!isEmpty">
<template v-if="images.length > 0 || name">
<div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader">
<div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div>
<div>
<gl-search-box-by-click
v-model="search"
v-model="searchValue"
:placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
@submit="requestImagesList({ name: $event })"
@clear="name = null"
@submit="name = $event"
/>
</div>
</div>
......@@ -207,9 +286,10 @@ export default {
<image-list
v-if="images.length"
:images="images"
:pagination="pagination"
@pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
:page-info="pageInfo"
@delete="deleteImage"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
/>
<gl-empty-state
......
......@@ -16,4 +16,5 @@
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
"group_path": @group.full_path,
character_error: @character_error.to_s } }
......@@ -17,6 +17,6 @@
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"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,
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
---
title: Refactor container registry list page to grapqhl
merge_request: 48602
author:
type: changed
......@@ -51,13 +51,6 @@ RSpec.describe 'Container Registry', :js do
expect(page).to have_content 'my/image'
end
it 'image repository delete is disabled' do
visit_container_registry
delete_btn = find('[title="Remove repository"]')
expect(delete_btn).to be_disabled
end
it 'navigates to repo details' do
visit_container_registry_details('my/image')
......
......@@ -94,7 +94,8 @@ RSpec.describe 'Container Registry', :js do
end
it('pagination navigate to the second page') do
visit_second_page
visit_details_second_page
expect(page).to have_content '20'
end
end
......@@ -116,22 +117,23 @@ RSpec.describe 'Container Registry', :js do
context 'when there are more than 10 images' do
before do
create_list(:container_repository, 12, project: project)
project.container_repositories << container_repository
create_list(:container_repository, 12, project: project)
visit_container_registry
end
it 'shows pagination' do
expect(page).to have_css '.gl-pagination'
expect(page).to have_css '.gl-keyset-pagination'
end
it 'pagination goes to second page' do
visit_second_page
visit_list_next_page
expect(page).to have_content 'my/image'
end
it 'pagination is preserved after navigating back from details' do
visit_second_page
visit_list_next_page
click_link 'my/image'
breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry'
......@@ -148,7 +150,12 @@ RSpec.describe 'Container Registry', :js do
click_link name
end
def visit_second_page
def visit_list_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
......
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Component from '~/registry/explorer/components/list_page/image_list_row.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
......@@ -11,13 +12,15 @@ import {
REMOVE_REPOSITORY_LABEL,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
} from '~/registry/explorer/constants';
import { RouterLink } from '../../stubs';
import { imagesListResponse } from '../../mock_data';
describe('Image List Row', () => {
let wrapper;
const item = imagesListResponse.data[0];
const [item] = imagesListResponse;
const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
......@@ -50,13 +53,15 @@ describe('Image List Row', () => {
describe('main tooltip', () => {
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
mountComponent();
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
});
it('is disabled when item is being deleted', () => {
mountComponent({ item: { ...item, deleting: true } });
mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false);
});
......@@ -65,12 +70,13 @@ describe('Image List Row', () => {
describe('image title and path', () => {
it('contains a link to the details page', () => {
mountComponent();
const link = findDetailsLink();
expect(link.html()).toContain(item.path);
expect(link.props('to')).toMatchObject({
name: 'details',
params: {
id: item.id,
id: getIdFromGraphQLId(item.id),
},
});
});
......@@ -85,16 +91,18 @@ describe('Image List Row', () => {
describe('warning icon', () => {
it.each`
failedDelete | cleanup_policy_started_at | shown | title
${true} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
${false} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
${false} | ${false} | ${false} | ${''}
status | expirationPolicyStartedAt | shown | title
${IMAGE_FAILED_DELETED_STATUS} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
${''} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
${''} | ${false} | ${false} | ${''}
`(
'when failedDelete is $failedDelete and cleanup_policy_started_at is $cleanup_policy_started_at',
({ cleanup_policy_started_at, failedDelete, shown, title }) => {
mountComponent({ item: { ...item, failedDelete, cleanup_policy_started_at } });
'when status is $status and expirationPolicyStartedAt is $expirationPolicyStartedAt',
({ expirationPolicyStartedAt, status, shown, title }) => {
mountComponent({ item: { ...item, status, expirationPolicyStartedAt } });
const icon = findWarningIcon();
expect(icon.exists()).toBe(shown);
if (shown) {
const tooltip = getBinding(icon.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(title);
......@@ -112,30 +120,33 @@ describe('Image List Row', () => {
it('has the correct props', () => {
mountComponent();
expect(findDeleteBtn().attributes()).toMatchObject({
expect(findDeleteBtn().props()).toMatchObject({
title: REMOVE_REPOSITORY_LABEL,
tooltipdisabled: `${Boolean(item.destroy_path)}`,
tooltiptitle: LIST_DELETE_BUTTON_DISABLED,
tooltipDisabled: item.canDelete,
tooltipTitle: LIST_DELETE_BUTTON_DISABLED,
});
});
it('emits a delete event', () => {
mountComponent();
findDeleteBtn().vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[item]]);
});
it.each`
destroy_path | deleting | state
${null} | ${null} | ${'true'}
${null} | ${true} | ${'true'}
${'foo'} | ${true} | ${'true'}
${'foo'} | ${false} | ${undefined}
canDelete | status | state
${false} | ${''} | ${true}
${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
${true} | ${''} | ${false}
`(
'disabled is $state when destroy_path is $destroy_path and deleting is $deleting',
({ destroy_path, deleting, state }) => {
mountComponent({ item: { ...item, destroy_path, deleting } });
expect(findDeleteBtn().attributes('disabled')).toBe(state);
'disabled is $state when canDelete is $canDelete and status is $status',
({ canDelete, status, state }) => {
mountComponent({ item: { ...item, canDelete, status } });
expect(findDeleteBtn().props('disabled')).toBe(state);
},
);
});
......@@ -155,11 +166,13 @@ describe('Image List Row', () => {
describe('tags count text', () => {
it('with one tag in the image', () => {
mountComponent({ item: { ...item, tags_count: 1 } });
mountComponent({ item: { ...item, tagsCount: 1 } });
expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
});
it('with more than one tag in the image', () => {
mountComponent({ item: { ...item, tags_count: 3 } });
mountComponent({ item: { ...item, tagsCount: 3 } });
expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import { GlKeysetPagination } from '@gitlab/ui';
import Component from '~/registry/explorer/components/list_page/image_list.vue';
import ImageListRow from '~/registry/explorer/components/list_page/image_list_row.vue';
import { imagesListResponse, imagePagination } from '../../mock_data';
import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data';
describe('Image List', () => {
let wrapper;
const findRow = () => wrapper.findAll(ImageListRow);
const findPagination = () => wrapper.find(GlPagination);
const findPagination = () => wrapper.find(GlKeysetPagination);
const mountComponent = () => {
const mountComponent = (pageInfo = defaultPageInfo) => {
wrapper = shallowMount(Component, {
propsData: {
images: imagesListResponse.data,
pagination: imagePagination,
images: imagesListResponse,
pageInfo,
},
});
};
beforeEach(() => {
mountComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -31,10 +27,14 @@ describe('Image List', () => {
describe('list', () => {
it('contains one list element for each image', () => {
expect(findRow().length).toBe(imagesListResponse.data.length);
mountComponent();
expect(findRow().length).toBe(imagesListResponse.length);
});
it('when delete event is emitted on the row it emits up a delete event', () => {
mountComponent();
findRow()
.at(0)
.vm.$emit('delete', 'foo');
......@@ -44,19 +44,41 @@ describe('Image List', () => {
describe('pagination', () => {
it('exists', () => {
mountComponent();
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(imagePagination.perPage);
expect(pagination.props('totalItems')).toBe(imagePagination.total);
expect(pagination.props('value')).toBe(imagePagination.page);
it.each`
hasNextPage | hasPreviousPage | isVisible
${true} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
`(
'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible',
({ hasNextPage, hasPreviousPage, isVisible }) => {
mountComponent({ hasNextPage, hasPreviousPage });
expect(findPagination().exists()).toBe(isVisible);
expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage);
expect(findPagination().props('hasNextPage')).toBe(hasNextPage);
},
);
it('emits "prev-page" when the user clicks the back page button', () => {
mountComponent({ hasPreviousPage: true });
findPagination().vm.$emit('prev');
expect(wrapper.emitted('prev-page')).toEqual([[]]);
});
it('emits a pageChange event when the page change', () => {
findPagination().vm.$emit(GlPagination.model.event, 2);
expect(wrapper.emitted('pageChange')).toEqual([[2]]);
it('emits "next-page" when the user clicks the forward page button', () => {
mountComponent({ hasNextPage: true });
findPagination().vm.$emit('next');
expect(wrapper.emitted('next-page')).toEqual([[]]);
});
});
});
......@@ -45,21 +45,32 @@ export const registryServerResponse = [
},
];
export const imagesListResponse = {
data: [
{
path: 'foo',
location: 'location',
destroy_path: 'path',
},
{
path: 'bar',
location: 'location-2',
destroy_path: 'path-2',
},
],
headers,
};
export const imagesListResponse = [
{
__typename: 'ContainerRepository',
id: 'gid://gitlab/ContainerRepository/26',
name: 'rails-12009',
path: 'gitlab-org/gitlab-test/rails-12009',
status: null,
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
tagsCount: 18,
expirationPolicyStartedAt: null,
},
{
__typename: 'ContainerRepository',
id: 'gid://gitlab/ContainerRepository/11',
name: 'rails-20572',
path: 'gitlab-org/gitlab-test/rails-20572',
status: null,
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
canDelete: true,
createdAt: '2020-09-21T06:57:43Z',
tagsCount: 1,
expirationPolicyStartedAt: null,
},
];
export const tagsListResponse = {
data: [
......@@ -90,12 +101,12 @@ export const tagsListResponse = {
headers,
};
export const imagePagination = {
perPage: 10,
page: 1,
total: 14,
totalPages: 2,
nextPage: 2,
export const pageInfo = {
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'eyJpZCI6IjI2In0',
endCursor: 'eyJpZCI6IjgifQ',
__typename: 'ContainerRepositoryConnection',
};
export const imageDetailsMock = {
......@@ -108,3 +119,76 @@ export const imageDetailsMock = {
cleanup_policy_started_at: null,
delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1',
};
export const graphQLImageListMock = {
data: {
project: {
__typename: 'Project',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
nodes: imagesListResponse,
pageInfo,
},
},
},
};
export const graphQLEmptyImageListMock = {
data: {
project: {
__typename: 'Project',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
nodes: [],
pageInfo,
},
},
},
};
export const graphQLEmptyGroupImageListMock = {
data: {
group: {
__typename: 'Group',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
nodes: [],
pageInfo,
},
},
},
};
export const deletedContainerRepository = {
id: 'gid://gitlab/ContainerRepository/11',
status: 'DELETE_SCHEDULED',
path: 'gitlab-org/gitlab-test/rails-12009',
__typename: 'ContainerRepository',
};
export const graphQLImageDeleteMock = {
data: {
destroyContainerRepository: {
containerRepository: {
...deletedContainerRepository,
},
errors: [],
__typename: 'DestroyContainerRepositoryPayload',
},
},
};
export const graphQLImageDeleteMockError = {
data: {
destroyContainerRepository: {
containerRepository: {
...deletedContainerRepository,
},
errors: ['foo'],
__typename: 'DestroyContainerRepositoryPayload',
},
},
};
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
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/list.vue';
......@@ -10,26 +12,36 @@ import RegistryHeader from '~/registry/explorer/components/list_page/registry_he
import ImageList from '~/registry/explorer/components/list_page/image_list.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { createStore } from '~/registry/explorer/stores/';
import {
SET_MAIN_LOADING,
SET_IMAGES_LIST_SUCCESS,
SET_PAGINATION,
SET_INITIAL_STATE,
} from '~/registry/explorer/stores/mutation_types';
import { SET_INITIAL_STATE } from '~/registry/explorer/stores/mutation_types';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
IMAGE_REPOSITORY_LIST_LABEL,
SEARCH_PLACEHOLDER_TEXT,
} from '~/registry/explorer/constants';
import { imagesListResponse } from '../mock_data';
import getProjectContainerRepositories from '~/registry/explorer/graphql/queries/get_project_container_repositories.graphql';
import getGroupContainerRepositories from '~/registry/explorer/graphql/queries/get_group_container_repositories.graphql';
import deleteContainerRepository from '~/registry/explorer/graphql/mutations/delete_container_repository.graphql';
import {
graphQLImageListMock,
graphQLImageDeleteMock,
deletedContainerRepository,
graphQLImageDeleteMockError,
graphQLEmptyImageListMock,
graphQLEmptyGroupImageListMock,
pageInfo,
} from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
import { $toast } from '../../shared/mocks';
const localVue = createLocalVue();
describe('List Page', () => {
let wrapper;
let dispatchSpy;
let store;
let apolloProvider;
const findDeleteModal = () => wrapper.find(GlModal);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
......@@ -47,8 +59,30 @@ describe('List Page', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const mountComponent = ({ mocks } = {}) => {
const waitForApolloRequestRender = async () => {
await waitForPromises();
await wrapper.vm.$nextTick();
};
const mountComponent = ({
mocks,
resolver = jest.fn().mockResolvedValue(graphQLImageListMock),
groupResolver = jest.fn().mockResolvedValue(graphQLImageListMock),
mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [
[getProjectContainerRepositories, resolver],
[getGroupContainerRepositories, groupResolver],
[deleteContainerRepository, mutationResolver],
];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(component, {
localVue,
apolloProvider,
store,
stubs: {
GlModal,
......@@ -69,37 +103,21 @@ describe('List Page', () => {
beforeEach(() => {
store = createStore();
dispatchSpy = jest.spyOn(store, 'dispatch');
dispatchSpy.mockResolvedValue();
store.commit(SET_IMAGES_LIST_SUCCESS, imagesListResponse.data);
store.commit(SET_PAGINATION, imagesListResponse.headers);
});
afterEach(() => {
wrapper.destroy();
});
describe('API calls', () => {
it.each`
imageList | name | called
${[]} | ${'foo'} | ${['requestImagesList']}
${imagesListResponse.data} | ${undefined} | ${['requestImagesList']}
${imagesListResponse.data} | ${'foo'} | ${undefined}
`(
'with images equal $imageList and name $name dispatch calls $called',
({ imageList, name, called }) => {
store.commit(SET_IMAGES_LIST_SUCCESS, imageList);
dispatchSpy.mockClear();
mountComponent({ mocks: { $route: { name } } });
expect(dispatchSpy.mock.calls[0]).toEqual(called);
},
);
});
it('contains registry header', () => {
it('contains registry header', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findRegistryHeader().exists()).toBe(true);
expect(findRegistryHeader().props()).toMatchObject({
imagesCount: 2,
});
});
describe('connection error', () => {
......@@ -111,7 +129,6 @@ describe('List Page', () => {
beforeEach(() => {
store.commit(SET_INITIAL_STATE, config);
mountComponent();
});
afterEach(() => {
......@@ -119,78 +136,103 @@ describe('List Page', () => {
});
it('should show an empty state', () => {
mountComponent();
expect(findEmptyState().exists()).toBe(true);
});
it('empty state should have an svg-path', () => {
mountComponent();
expect(findEmptyState().attributes('svg-path')).toBe(config.containersErrorImage);
});
it('empty state should have a description', () => {
mountComponent();
expect(findEmptyState().html()).toContain('connection error');
});
it('should not show the loading or default state', () => {
mountComponent();
expect(findSkeletonLoader().exists()).toBe(false);
expect(findImageList().exists()).toBe(false);
});
});
describe('isLoading is true', () => {
beforeEach(() => {
store.commit(SET_MAIN_LOADING, true);
it('shows the skeleton loader', () => {
mountComponent();
});
afterEach(() => store.commit(SET_MAIN_LOADING, false));
it('shows the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('imagesList is not visible', () => {
mountComponent();
expect(findImageList().exists()).toBe(false);
});
it('cli commands is not visible', () => {
mountComponent();
expect(findCliCommands().exists()).toBe(false);
});
});
describe('list is empty', () => {
beforeEach(() => {
store.commit(SET_IMAGES_LIST_SUCCESS, []);
mountComponent();
return waitForPromises();
});
describe('project page', () => {
const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock);
it('cli commands is not visible', () => {
expect(findCliCommands().exists()).toBe(false);
});
it('cli commands is not visible', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
expect(findCliCommands().exists()).toBe(false);
});
it('project empty state is visible', async () => {
mountComponent({ resolver });
await waitForApolloRequestRender();
it('project empty state is visible', () => {
expect(findProjectEmptyState().exists()).toBe(true);
expect(findProjectEmptyState().exists()).toBe(true);
});
});
describe('group page', () => {
const groupResolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock);
describe('is group page is true', () => {
beforeEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: true });
mountComponent();
});
afterEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: undefined });
});
it('group empty state is visible', () => {
it('group empty state is visible', async () => {
mountComponent({ groupResolver });
await waitForApolloRequestRender();
expect(findGroupEmptyState().exists()).toBe(true);
});
it('cli commands is not visible', () => {
it('cli commands is not visible', async () => {
mountComponent({ groupResolver });
await waitForApolloRequestRender();
expect(findCliCommands().exists()).toBe(false);
});
it('list header is not visible', () => {
it('list header is not visible', async () => {
mountComponent({ groupResolver });
await waitForApolloRequestRender();
expect(findListHeader().exists()).toBe(false);
});
});
......@@ -198,55 +240,91 @@ describe('List Page', () => {
describe('list is not empty', () => {
describe('unfiltered state', () => {
beforeEach(() => {
it('quick start is visible', async () => {
mountComponent();
});
it('quick start is visible', () => {
await waitForApolloRequestRender();
expect(findCliCommands().exists()).toBe(true);
});
it('list component is visible', () => {
it('list component is visible', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findImageList().exists()).toBe(true);
});
it('list header is visible', () => {
it('list header is visible', async () => {
mountComponent();
await waitForApolloRequestRender();
const header = findListHeader();
expect(header.exists()).toBe(true);
expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
describe('delete image', () => {
const itemToDelete = { path: 'bar' };
it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue();
findImageList().vm.$emit('delete', itemToDelete);
expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
const deleteImage = async () => {
await wrapper.vm.$nextTick();
findImageList().vm.$emit('delete', deletedContainerRepository);
findDeleteModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage',
wrapper.vm.itemToDelete,
await waitForApolloRequestRender();
};
it('should call deleteItem when confirming deletion', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
mountComponent({ mutationResolver });
await deleteImage();
expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository);
expect(mutationResolver).toHaveBeenCalledWith({ id: deletedContainerRepository.id });
const updatedImage = findImageList()
.props('images')
.find(i => i.id === deletedContainerRepository.id);
expect(updatedImage.status).toBe(deletedContainerRepository.status);
});
it('should show a success alert when delete request is successful', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
mountComponent({ mutationResolver });
await deleteImage();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
it('should show a success alert when delete request is successful', () => {
dispatchSpy.mockResolvedValue();
findImageList().vm.$emit('delete', itemToDelete);
expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
describe('when delete request fails it shows an alert', () => {
it('user recoverable error', async () => {
const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMockError);
mountComponent({ mutationResolver });
await deleteImage();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
});
it('should show an error alert when delete request fails', () => {
dispatchSpy.mockRejectedValue();
findImageList().vm.$emit('delete', itemToDelete);
expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
it('network error', async () => {
const mutationResolver = jest.fn().mockRejectedValue();
mountComponent({ mutationResolver });
await deleteImage();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
......@@ -258,38 +336,68 @@ describe('List Page', () => {
});
describe('search', () => {
it('has a search box element', () => {
const doSearch = async () => {
await waitForApolloRequestRender();
findSearchBox().vm.$emit('submit', 'centos6');
await wrapper.vm.$nextTick();
};
it('has a search box element', async () => {
mountComponent();
await waitForApolloRequestRender();
const searchBox = findSearchBox();
expect(searchBox.exists()).toBe(true);
expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT);
});
it('performs a search', () => {
mountComponent();
findSearchBox().vm.$emit('submit', 'foo');
expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
name: 'foo',
});
it('performs a search', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ resolver });
await doSearch();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' }));
});
it('when search result is empty displays an empty search message', () => {
mountComponent();
store.commit(SET_IMAGES_LIST_SUCCESS, []);
return wrapper.vm.$nextTick().then(() => {
expect(findEmptySearchMessage().exists()).toBe(true);
});
it('when search result is empty displays an empty search message', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ resolver });
resolver.mockResolvedValue(graphQLEmptyImageListMock);
await doSearch();
expect(findEmptySearchMessage().exists()).toBe(true);
});
});
describe('pagination', () => {
it('pageChange event triggers the appropriate store function', () => {
mountComponent();
findImageList().vm.$emit('pageChange', 2);
expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
pagination: { page: 2 },
name: wrapper.vm.search,
});
it('prev-page event triggers a fetchMore request', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ resolver });
await waitForApolloRequestRender();
findImageList().vm.$emit('prev-page');
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ first: null, before: pageInfo.startCursor }),
);
});
it('next-page event triggers a fetchMore request', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ resolver });
await waitForApolloRequestRender();
findImageList().vm.$emit('next-page');
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pageInfo.endCursor }),
);
});
});
});
......@@ -324,11 +432,11 @@ describe('List Page', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
dispatchSpy.mockResolvedValue();
});
it('send an event when delete button is clicked', () => {
findImageList().vm.$emit('delete', {});
testTrackingCall('click_button');
});
......
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