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> <script>
import { GlPagination } from '@gitlab/ui'; import { GlKeysetPagination } from '@gitlab/ui';
import ImageListRow from './image_list_row.vue'; import ImageListRow from './image_list_row.vue';
export default { export default {
name: 'ImageList', name: 'ImageList',
components: { components: {
GlPagination, GlKeysetPagination,
ImageListRow, ImageListRow,
}, },
props: { props: {
...@@ -13,19 +13,14 @@ export default { ...@@ -13,19 +13,14 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
pagination: { pageInfo: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
computed: { computed: {
currentPage: { showPagination() {
get() { return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
return this.pagination.page;
},
set(page) {
this.$emit('pageChange', page);
},
}, },
}, },
}; };
...@@ -40,13 +35,15 @@ export default { ...@@ -40,13 +35,15 @@ export default {
:first="index === 0" :first="index === 0"
@delete="$emit('delete', $event)" @delete="$emit('delete', $event)"
/> />
<div class="gl-display-flex gl-justify-content-center">
<gl-pagination <gl-keyset-pagination
v-model="currentPage" v-if="showPagination"
:per-page="pagination.perPage" :has-next-page="pageInfo.hasNextPage"
:total-items="pagination.total" :has-previous-page="pageInfo.hasPreviousPage"
align="center" class="gl-mt-3"
class="w-100 gl-mt-3" @prev="$emit('prev-page')"
@next="$emit('next-page')"
/> />
</div> </div>
</div>
</template> </template>
<script> <script>
import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui'; import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue';
import DeleteButton from '../delete_button.vue'; import DeleteButton from '../delete_button.vue';
...@@ -11,6 +13,8 @@ import { ...@@ -11,6 +13,8 @@ import {
REMOVE_REPOSITORY_LABEL, REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION, ROW_SCHEDULED_FOR_DELETION,
CLEANUP_TIMED_OUT_ERROR_MESSAGE, CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
} from '../../constants/index'; } from '../../constants/index';
export default { export default {
...@@ -38,19 +42,29 @@ export default { ...@@ -38,19 +42,29 @@ export default {
}, },
computed: { computed: {
disabledDelete() { 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() { tagsCountText() {
return n__( return n__(
'ContainerRegistry|%{count} Tag', 'ContainerRegistry|%{count} Tag',
'ContainerRegistry|%{count} Tags', 'ContainerRegistry|%{count} Tags',
this.item.tags_count, this.item.tagsCount,
); );
}, },
warningIconText() { warningIconText() {
if (this.item.failedDelete) { if (this.failedDelete) {
return ASYNC_DELETE_IMAGE_ERROR_MESSAGE; 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 CLEANUP_TIMED_OUT_ERROR_MESSAGE;
} }
return null; return null;
...@@ -63,23 +77,23 @@ export default { ...@@ -63,23 +77,23 @@ export default {
<list-item <list-item
v-gl-tooltip="{ v-gl-tooltip="{
placement: 'left', placement: 'left',
disabled: !item.deleting, disabled: !deleting,
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
}" }"
v-bind="$attrs" v-bind="$attrs"
:disabled="item.deleting" :disabled="deleting"
> >
<template #left-primary> <template #left-primary>
<router-link <router-link
class="gl-text-body gl-font-weight-bold" class="gl-text-body gl-font-weight-bold"
data-testid="details-link" data-testid="details-link"
:to="{ name: 'details', params: { id: item.id } }" :to="{ name: 'details', params: { id } }"
> >
{{ item.path }} {{ item.path }}
</router-link> </router-link>
<clipboard-button <clipboard-button
v-if="item.location" v-if="item.location"
:disabled="item.deleting" :disabled="deleting"
:text="item.location" :text="item.location"
:title="item.location" :title="item.location"
category="tertiary" category="tertiary"
...@@ -97,7 +111,7 @@ export default { ...@@ -97,7 +111,7 @@ export default {
<gl-icon name="tag" class="gl-mr-2" /> <gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText"> <gl-sprintf :message="tagsCountText">
<template #count> <template #count>
{{ item.tags_count }} {{ item.tagsCount }}
</template> </template>
</gl-sprintf> </gl-sprintf>
</span> </span>
...@@ -106,7 +120,7 @@ export default { ...@@ -106,7 +120,7 @@ export default {
<delete-button <delete-button
:title="$options.i18n.REMOVE_REPOSITORY_LABEL" :title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete" :disabled="disabledDelete"
:tooltip-disabled="Boolean(item.destroy_path)" :tooltip-disabled="item.canDelete"
:tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
@delete="$emit('delete', item)" @delete="$emit('delete', item)"
/> />
......
...@@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__( ...@@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__(
// Parameters // Parameters
export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; 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'; ...@@ -5,6 +5,7 @@ import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue'; import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
import { createStore } from './stores'; import { createStore } from './stores';
import createRouter from './router'; import createRouter from './router';
import { apolloProvider } from './graphql/index';
Vue.use(Translate); Vue.use(Translate);
Vue.use(GlToast); Vue.use(GlToast);
...@@ -27,6 +28,7 @@ export default () => { ...@@ -27,6 +28,7 @@ export default () => {
el, el,
store, store,
router, router,
apolloProvider,
components: { components: {
RegistryExplorer, RegistryExplorer,
}, },
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState } from 'vuex';
import { import {
GlEmptyState, GlEmptyState,
GlTooltipDirective, GlTooltipDirective,
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
GlSearchBoxByClick, GlSearchBoxByClick,
} from '@gitlab/ui'; } from '@gitlab/ui';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import createFlash from '~/flash';
import ProjectEmptyState from '../components/list_page/project_empty_state.vue'; import ProjectEmptyState from '../components/list_page/project_empty_state.vue';
import GroupEmptyState from '../components/list_page/group_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'; ...@@ -18,6 +19,10 @@ import RegistryHeader from '../components/list_page/registry_header.vue';
import ImageList from '../components/list_page/image_list.vue'; import ImageList from '../components/list_page/image_list.vue';
import CliCommands from '../components/list_page/cli_commands.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 { import {
DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE,
...@@ -29,6 +34,8 @@ import { ...@@ -29,6 +34,8 @@ import {
IMAGE_REPOSITORY_LIST_LABEL, IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE, EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE, EMPTY_RESULT_MESSAGE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../constants/index'; } from '../constants/index';
export default { export default {
...@@ -66,21 +73,63 @@ export default { ...@@ -66,21 +73,63 @@ export default {
EMPTY_RESULT_TITLE, EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE, 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() { data() {
return { return {
images: [],
pageInfo: {},
containerRepositoriesCount: 0,
itemToDelete: {}, itemToDelete: {},
deleteAlertType: null, deleteAlertType: null,
search: null, searchValue: null,
isEmpty: false, name: null,
mutationLoading: false,
}; };
}, },
computed: { 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() { tracking() {
return { return {
label: 'registry_repository_delete', label: 'registry_repository_delete',
}; };
}, },
isLoading() {
return this.$apollo.queries.images.loading || this.mutationLoading;
},
showCommands() { showCommands() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
}, },
...@@ -93,19 +142,7 @@ export default { ...@@ -93,19 +142,7 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE; : DELETE_IMAGE_ERROR_MESSAGE;
}, },
}, },
mounted() {
this.loadImageList(this.$route.name);
},
methods: { 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) { deleteImage(item) {
this.track('click_button'); this.track('click_button');
this.itemToDelete = item; this.itemToDelete = item;
...@@ -113,18 +150,59 @@ export default { ...@@ -113,18 +150,59 @@ export default {
}, },
handleDeleteImage() { handleDeleteImage() {
this.track('confirm_delete'); this.track('confirm_delete');
return this.requestDeleteImage(this.itemToDelete) this.mutationLoading = true;
.then(() => { 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'; this.deleteAlertType = 'success';
}
}) })
.catch(() => { .catch(() => {
this.deleteAlertType = 'danger'; this.deleteAlertType = 'danger';
})
.finally(() => {
this.mutationLoading = false;
}); });
}, },
dismissDeleteAlert() { dismissDeleteAlert() {
this.deleteAlertType = null; this.deleteAlertType = null;
this.itemToDelete = {}; 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> </script>
...@@ -134,7 +212,7 @@ export default { ...@@ -134,7 +212,7 @@ export default {
<gl-alert <gl-alert
v-if="showDeleteAlert" v-if="showDeleteAlert"
:variant="deleteAlertType" :variant="deleteAlertType"
class="mt-2" class="gl-mt-5"
dismissible dismissible
@dismiss="dismissDeleteAlert" @dismiss="dismissDeleteAlert"
> >
...@@ -165,7 +243,7 @@ export default { ...@@ -165,7 +243,7 @@ export default {
<template v-else> <template v-else>
<registry-header <registry-header
:images-count="pagination.total" :images-count="containerRepositoriesCount"
:expiration-policy="config.expirationPolicy" :expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath" :help-page-path="config.helpPagePath"
:expiration-policy-help-page-path="config.expirationPolicyHelpPagePath" :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
...@@ -176,7 +254,7 @@ export default { ...@@ -176,7 +254,7 @@ export default {
</template> </template>
</registry-header> </registry-header>
<div v-if="isLoading" class="mt-2"> <div v-if="isLoading" class="gl-mt-5">
<gl-skeleton-loader <gl-skeleton-loader
v-for="index in $options.loader.repeat" v-for="index in $options.loader.repeat"
:key="index" :key="index"
...@@ -190,16 +268,17 @@ export default { ...@@ -190,16 +268,17 @@ export default {
</gl-skeleton-loader> </gl-skeleton-loader>
</div> </div>
<template v-else> <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-display-flex gl-p-1 gl-mt-3" data-testid="listHeader">
<div class="gl-flex-fill-1"> <div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div> </div>
<div> <div>
<gl-search-box-by-click <gl-search-box-by-click
v-model="search" v-model="searchValue"
:placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT" :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
@submit="requestImagesList({ name: $event })" @clear="name = null"
@submit="name = $event"
/> />
</div> </div>
</div> </div>
...@@ -207,9 +286,10 @@ export default { ...@@ -207,9 +286,10 @@ export default {
<image-list <image-list
v-if="images.length" v-if="images.length"
:images="images" :images="images"
:pagination="pagination" :page-info="pageInfo"
@pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
@delete="deleteImage" @delete="deleteImage"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
/> />
<gl-empty-state <gl-empty-state
......
...@@ -16,4 +16,5 @@ ...@@ -16,4 +16,5 @@
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'), "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_admin": current_user&.admin.to_s,
is_group_page: "true", is_group_page: "true",
"group_path": @group.full_path,
character_error: @character_error.to_s } } character_error: @character_error.to_s } }
...@@ -17,6 +17,6 @@ ...@@ -17,6 +17,6 @@
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), "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'), "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'), "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, "is_admin": current_user&.admin.to_s,
character_error: @character_error.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 ...@@ -51,13 +51,6 @@ RSpec.describe 'Container Registry', :js do
expect(page).to have_content 'my/image' expect(page).to have_content 'my/image'
end 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 it 'navigates to repo details' do
visit_container_registry_details('my/image') visit_container_registry_details('my/image')
......
...@@ -94,7 +94,8 @@ RSpec.describe 'Container Registry', :js do ...@@ -94,7 +94,8 @@ RSpec.describe 'Container Registry', :js do
end end
it('pagination navigate to the second page') do it('pagination navigate to the second page') do
visit_second_page visit_details_second_page
expect(page).to have_content '20' expect(page).to have_content '20'
end end
end end
...@@ -116,22 +117,23 @@ RSpec.describe 'Container Registry', :js do ...@@ -116,22 +117,23 @@ RSpec.describe 'Container Registry', :js do
context 'when there are more than 10 images' do context 'when there are more than 10 images' do
before do before do
create_list(:container_repository, 12, project: project)
project.container_repositories << container_repository project.container_repositories << container_repository
create_list(:container_repository, 12, project: project)
visit_container_registry visit_container_registry
end end
it 'shows pagination' do it 'shows pagination' do
expect(page).to have_css '.gl-pagination' expect(page).to have_css '.gl-keyset-pagination'
end end
it 'pagination goes to second page' do it 'pagination goes to second page' do
visit_second_page visit_list_next_page
expect(page).to have_content 'my/image' expect(page).to have_content 'my/image'
end end
it 'pagination is preserved after navigating back from details' do it 'pagination is preserved after navigating back from details' do
visit_second_page visit_list_next_page
click_link 'my/image' click_link 'my/image'
breadcrumb = find '.breadcrumbs' breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry' breadcrumb.click_link 'Container Registry'
...@@ -148,7 +150,12 @@ RSpec.describe 'Container Registry', :js do ...@@ -148,7 +150,12 @@ RSpec.describe 'Container Registry', :js do
click_link name click_link name
end 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 = find '.gl-pagination'
pagination.click_link '2' pagination.click_link '2'
end end
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlSprintf } from '@gitlab/ui'; import { GlIcon, GlSprintf } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; import Component from '~/registry/explorer/components/list_page/image_list_row.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue';
...@@ -11,13 +12,15 @@ import { ...@@ -11,13 +12,15 @@ import {
REMOVE_REPOSITORY_LABEL, REMOVE_REPOSITORY_LABEL,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE, ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CLEANUP_TIMED_OUT_ERROR_MESSAGE, CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
} from '~/registry/explorer/constants'; } from '~/registry/explorer/constants';
import { RouterLink } from '../../stubs'; import { RouterLink } from '../../stubs';
import { imagesListResponse } from '../../mock_data'; import { imagesListResponse } from '../../mock_data';
describe('Image List Row', () => { describe('Image List Row', () => {
let wrapper; let wrapper;
const item = imagesListResponse.data[0]; const [item] = imagesListResponse;
const findDetailsLink = () => wrapper.find('[data-testid="details-link"]'); const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]'); const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
...@@ -50,13 +53,15 @@ describe('Image List Row', () => { ...@@ -50,13 +53,15 @@ describe('Image List Row', () => {
describe('main tooltip', () => { describe('main tooltip', () => {
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
mountComponent(); mountComponent();
const tooltip = getBinding(wrapper.element, 'gl-tooltip'); const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).toBeDefined(); expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
}); });
it('is disabled when item is being deleted', () => { 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'); const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false); expect(tooltip.value.disabled).toBe(false);
}); });
...@@ -65,12 +70,13 @@ describe('Image List Row', () => { ...@@ -65,12 +70,13 @@ describe('Image List Row', () => {
describe('image title and path', () => { describe('image title and path', () => {
it('contains a link to the details page', () => { it('contains a link to the details page', () => {
mountComponent(); mountComponent();
const link = findDetailsLink(); const link = findDetailsLink();
expect(link.html()).toContain(item.path); expect(link.html()).toContain(item.path);
expect(link.props('to')).toMatchObject({ expect(link.props('to')).toMatchObject({
name: 'details', name: 'details',
params: { params: {
id: item.id, id: getIdFromGraphQLId(item.id),
}, },
}); });
}); });
...@@ -85,16 +91,18 @@ describe('Image List Row', () => { ...@@ -85,16 +91,18 @@ describe('Image List Row', () => {
describe('warning icon', () => { describe('warning icon', () => {
it.each` it.each`
failedDelete | cleanup_policy_started_at | shown | title status | expirationPolicyStartedAt | shown | title
${true} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE} ${IMAGE_FAILED_DELETED_STATUS} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
${false} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE} ${''} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
${false} | ${false} | ${false} | ${''} ${''} | ${false} | ${false} | ${''}
`( `(
'when failedDelete is $failedDelete and cleanup_policy_started_at is $cleanup_policy_started_at', 'when status is $status and expirationPolicyStartedAt is $expirationPolicyStartedAt',
({ cleanup_policy_started_at, failedDelete, shown, title }) => { ({ expirationPolicyStartedAt, status, shown, title }) => {
mountComponent({ item: { ...item, failedDelete, cleanup_policy_started_at } }); mountComponent({ item: { ...item, status, expirationPolicyStartedAt } });
const icon = findWarningIcon(); const icon = findWarningIcon();
expect(icon.exists()).toBe(shown); expect(icon.exists()).toBe(shown);
if (shown) { if (shown) {
const tooltip = getBinding(icon.element, 'gl-tooltip'); const tooltip = getBinding(icon.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(title); expect(tooltip.value.title).toBe(title);
...@@ -112,30 +120,33 @@ describe('Image List Row', () => { ...@@ -112,30 +120,33 @@ describe('Image List Row', () => {
it('has the correct props', () => { it('has the correct props', () => {
mountComponent(); mountComponent();
expect(findDeleteBtn().attributes()).toMatchObject({
expect(findDeleteBtn().props()).toMatchObject({
title: REMOVE_REPOSITORY_LABEL, title: REMOVE_REPOSITORY_LABEL,
tooltipdisabled: `${Boolean(item.destroy_path)}`, tooltipDisabled: item.canDelete,
tooltiptitle: LIST_DELETE_BUTTON_DISABLED, tooltipTitle: LIST_DELETE_BUTTON_DISABLED,
}); });
}); });
it('emits a delete event', () => { it('emits a delete event', () => {
mountComponent(); mountComponent();
findDeleteBtn().vm.$emit('delete'); findDeleteBtn().vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[item]]); expect(wrapper.emitted('delete')).toEqual([[item]]);
}); });
it.each` it.each`
destroy_path | deleting | state canDelete | status | state
${null} | ${null} | ${'true'} ${false} | ${''} | ${true}
${null} | ${true} | ${'true'} ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
${'foo'} | ${true} | ${'true'} ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
${'foo'} | ${false} | ${undefined} ${true} | ${''} | ${false}
`( `(
'disabled is $state when destroy_path is $destroy_path and deleting is $deleting', 'disabled is $state when canDelete is $canDelete and status is $status',
({ destroy_path, deleting, state }) => { ({ canDelete, status, state }) => {
mountComponent({ item: { ...item, destroy_path, deleting } }); mountComponent({ item: { ...item, canDelete, status } });
expect(findDeleteBtn().attributes('disabled')).toBe(state);
expect(findDeleteBtn().props('disabled')).toBe(state);
}, },
); );
}); });
...@@ -155,11 +166,13 @@ describe('Image List Row', () => { ...@@ -155,11 +166,13 @@ describe('Image List Row', () => {
describe('tags count text', () => { describe('tags count text', () => {
it('with one tag in the image', () => { it('with one tag in the image', () => {
mountComponent({ item: { ...item, tags_count: 1 } }); mountComponent({ item: { ...item, tagsCount: 1 } });
expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
}); });
it('with more than one tag in the image', () => { 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'); expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; 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 Component from '~/registry/explorer/components/list_page/image_list.vue';
import ImageListRow from '~/registry/explorer/components/list_page/image_list_row.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', () => { describe('Image List', () => {
let wrapper; let wrapper;
const findRow = () => wrapper.findAll(ImageListRow); const findRow = () => wrapper.findAll(ImageListRow);
const findPagination = () => wrapper.find(GlPagination); const findPagination = () => wrapper.find(GlKeysetPagination);
const mountComponent = () => { const mountComponent = (pageInfo = defaultPageInfo) => {
wrapper = shallowMount(Component, { wrapper = shallowMount(Component, {
propsData: { propsData: {
images: imagesListResponse.data, images: imagesListResponse,
pagination: imagePagination, pageInfo,
}, },
}); });
}; };
beforeEach(() => {
mountComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -31,10 +27,14 @@ describe('Image List', () => { ...@@ -31,10 +27,14 @@ describe('Image List', () => {
describe('list', () => { describe('list', () => {
it('contains one list element for each image', () => { 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', () => { it('when delete event is emitted on the row it emits up a delete event', () => {
mountComponent();
findRow() findRow()
.at(0) .at(0)
.vm.$emit('delete', 'foo'); .vm.$emit('delete', 'foo');
...@@ -44,19 +44,41 @@ describe('Image List', () => { ...@@ -44,19 +44,41 @@ describe('Image List', () => {
describe('pagination', () => { describe('pagination', () => {
it('exists', () => { it('exists', () => {
mountComponent();
expect(findPagination().exists()).toBe(true); expect(findPagination().exists()).toBe(true);
}); });
it('is wired to the correct pagination props', () => { it.each`
const pagination = findPagination(); hasNextPage | hasPreviousPage | isVisible
expect(pagination.props('perPage')).toBe(imagePagination.perPage); ${true} | ${true} | ${true}
expect(pagination.props('totalItems')).toBe(imagePagination.total); ${true} | ${false} | ${true}
expect(pagination.props('value')).toBe(imagePagination.page); ${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', () => { it('emits "next-page" when the user clicks the forward page button', () => {
findPagination().vm.$emit(GlPagination.model.event, 2); mountComponent({ hasNextPage: true });
expect(wrapper.emitted('pageChange')).toEqual([[2]]);
findPagination().vm.$emit('next');
expect(wrapper.emitted('next-page')).toEqual([[]]);
}); });
}); });
}); });
...@@ -45,21 +45,32 @@ export const registryServerResponse = [ ...@@ -45,21 +45,32 @@ export const registryServerResponse = [
}, },
]; ];
export const imagesListResponse = { export const imagesListResponse = [
data: [
{ {
path: 'foo', __typename: 'ContainerRepository',
location: 'location', id: 'gid://gitlab/ContainerRepository/26',
destroy_path: 'path', 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,
}, },
{ {
path: 'bar', __typename: 'ContainerRepository',
location: 'location-2', id: 'gid://gitlab/ContainerRepository/11',
destroy_path: 'path-2', 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,
}, },
], ];
headers,
};
export const tagsListResponse = { export const tagsListResponse = {
data: [ data: [
...@@ -90,12 +101,12 @@ export const tagsListResponse = { ...@@ -90,12 +101,12 @@ export const tagsListResponse = {
headers, headers,
}; };
export const imagePagination = { export const pageInfo = {
perPage: 10, hasNextPage: true,
page: 1, hasPreviousPage: true,
total: 14, startCursor: 'eyJpZCI6IjI2In0',
totalPages: 2, endCursor: 'eyJpZCI6IjgifQ',
nextPage: 2, __typename: 'ContainerRepositoryConnection',
}; };
export const imageDetailsMock = { export const imageDetailsMock = {
...@@ -108,3 +119,76 @@ export const imageDetailsMock = { ...@@ -108,3 +119,76 @@ export const imageDetailsMock = {
cleanup_policy_started_at: null, cleanup_policy_started_at: null,
delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1', 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',
},
},
};
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