Commit a797d196 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Natalia Tepluhina

Refactor and connect package list for GraphQl implementation

parent 8cd2e168
......@@ -4,7 +4,7 @@
* For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846
* This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136
*/
// import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
......@@ -15,17 +15,18 @@ import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
LIST_QUERY_DEBOUNCE_TIME,
GRAPHQL_PAGE_SIZE,
} from '~/packages_and_registries/package_registry/constants';
import PackageTitle from './package_title.vue';
import PackageSearch from './package_search.vue';
// import PackageList from './packages_list.vue';
import PackageList from './packages_list.vue';
export default {
components: {
// GlEmptyState,
// GlLink,
// GlSprintf,
// PackageList,
GlEmptyState,
GlLink,
GlSprintf,
PackageList,
PackageTitle,
PackageSearch,
},
......@@ -64,17 +65,24 @@ export default {
groupSort: this.isGroupPage ? this.sort : undefined,
packageName: this.filters?.packageName,
packageType: this.filters?.packageType,
first: GRAPHQL_PAGE_SIZE,
};
},
graphqlResource() {
return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
},
pageInfo() {
return this.packages?.pageInfo ?? {};
},
packagesCount() {
return this.packages?.count;
},
hasFilters() {
return this.filters.packageName && this.filters.packageType;
},
emptySearch() {
return !this.filters.packageName && !this.filters.packageType;
},
emptyStateTitle() {
return this.emptySearch
? this.$options.i18n.emptyPageTitle
......@@ -99,6 +107,35 @@ export default {
this.sort = sort;
this.filters = { ...filters };
},
updateQuery(_, { fetchMoreResult }) {
return fetchMoreResult;
},
fetchNextPage() {
const variables = {
...this.queryVariables,
first: GRAPHQL_PAGE_SIZE,
last: null,
after: this.pageInfo?.endCursor,
};
this.$apollo.queries.packages.fetchMore({
variables,
updateQuery: this.updateQuery,
});
},
fetchPreviousPage() {
const variables = {
...this.queryVariables,
first: null,
last: GRAPHQL_PAGE_SIZE,
before: this.pageInfo?.startCursor,
};
this.$apollo.queries.packages.fetchMore({
variables,
updateQuery: this.updateQuery,
});
},
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
......@@ -116,7 +153,13 @@ export default {
<package-title :help-url="packageHelpUrl" :count="packagesCount" />
<package-search @update="handleSearchUpdate" />
<!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<package-list
:list="packages.nodes"
:is-loading="$apollo.queries.packages.loading"
:page-info="pageInfo"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
>
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
<template #description>
......@@ -129,6 +172,6 @@ export default {
</template>
</gl-empty-state>
</template>
</package-list> -->
</package-list>
</div>
</template>
<script>
import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui';
import { s__ } from '~/locale';
import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
import { TrackingActions } from '~/packages/shared/constants';
import { packageTypeToTrackCategory } from '~/packages/shared/utils';
import {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import Tracking from '~/tracking';
export default {
components: {
GlPagination,
GlKeysetPagination,
GlModal,
GlSprintf,
PackagesListLoader,
PackagesListRow,
},
mixins: [Tracking.mixin()],
props: {
list: {
type: Array,
required: false,
default: () => [],
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
pageInfo: {
type: Object,
required: true,
},
},
data() {
return {
itemToBeDeleted: null,
};
},
computed: {
...mapState({
perPage: (state) => state.pagination.perPage,
totalItems: (state) => state.pagination.total,
page: (state) => state.pagination.page,
isGroupPage: (state) => state.config.isGroupPage,
isLoading: 'isLoading',
}),
...mapGetters({ list: 'getList' }),
currentPage: {
get() {
return this.page;
},
set(value) {
this.$emit('page:changed', value);
},
},
isListEmpty() {
return !this.list || this.list.length === 0;
},
modalAction() {
return s__('PackageRegistry|Delete package');
},
deletePackageName() {
return this.itemToBeDeleted?.name ?? '';
},
tracking() {
const category = this.itemToBeDeleted
? packageTypeToTrackCategory(this.itemToBeDeleted.package_type)
? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
: undefined;
return {
category,
};
},
showPagination() {
return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
},
},
methods: {
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
this.track(TrackingActions.REQUEST_DELETE_PACKAGE);
this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
this.$refs.packageListDeleteModal.show();
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
this.track(TrackingActions.DELETE_PACKAGE);
this.track(DELETE_PACKAGE_TRACKING_ACTION);
this.itemToBeDeleted = null;
},
deleteItemCanceled() {
this.track(TrackingActions.CANCEL_DELETE_PACKAGE);
this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
this.itemToBeDeleted = null;
},
},
......@@ -77,6 +81,7 @@ export default {
deleteModalContent: s__(
'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?',
),
modalAction: s__('PackageRegistry|Delete package'),
},
};
</script>
......@@ -95,19 +100,19 @@ export default {
v-for="packageEntity in list"
:key="packageEntity.id"
:package-entity="packageEntity"
:package-link="packageEntity._links.web_path"
:is-group="isGroupPage"
@packageToDelete="setItemToBeDeleted"
/>
</div>
<gl-pagination
v-model="currentPage"
:per-page="perPage"
:total-items="totalItems"
align="center"
class="gl-w-full gl-mt-3"
/>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
v-bind="pageInfo"
class="gl-mt-3"
@prev="$emit('prev-page')"
@next="$emit('next-page')"
/>
</div>
<gl-modal
ref="packageListDeleteModal"
......@@ -116,8 +121,8 @@ export default {
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
>
<template #modal-title>{{ modalAction }}</template>
<template #modal-ok>{{ modalAction }}</template>
<template #modal-title>{{ $options.i18n.modalAction }}</template>
<template #modal-ok>{{ $options.i18n.modalAction }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #name>
<strong>{{ deletePackageName }}</strong>
......
......@@ -59,12 +59,6 @@ export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND =
export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
'copy_composer_package_include_command';
export const TrackingCategories = {
[PACKAGE_TYPE_MAVEN]: 'MavenPackages',
[PACKAGE_TYPE_NPM]: 'NpmPackages',
[PACKAGE_TYPE_CONAN]: 'ConanPackages',
};
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
......@@ -93,3 +87,4 @@ export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance';
export const PROJECT_RESOURCE_TYPE = 'project';
export const GROUP_RESOURCE_TYPE = 'group';
export const LIST_QUERY_DEBOUNCE_TIME = 50;
export const GRAPHQL_PAGE_SIZE = 20;
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getPackages(
$fullPath: ID!
......@@ -7,21 +8,47 @@ query getPackages(
$groupSort: PackageGroupSort
$packageName: String
$packageType: PackageTypeEnum
$first: Int
$last: Int
$after: String
$before: String
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
packages(sort: $sort, packageName: $packageName, packageType: $packageType) {
packages(
sort: $sort
packageName: $packageName
packageType: $packageType
after: $after
before: $before
first: $first
last: $last
) {
count
nodes {
...PackageData
}
pageInfo {
...PageInfo
}
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) {
packages(
sort: $groupSort
packageName: $packageName
packageType: $packageType
after: $after
before: $before
first: $first
last: $last
) {
count
nodes {
...PackageData
}
pageInfo {
...PageInfo
}
}
}
}
import { capitalize } from 'lodash';
import { s__ } from '~/locale';
import {
PACKAGE_TYPE_CONAN,
......@@ -38,3 +39,5 @@ export const getPackageTypeLabel = (packageType) => {
return null;
}
};
export const packageTypeToTrackCategory = (type) => `UI::${capitalize(type)}Packages`;
......@@ -8,5 +8,62 @@ exports[`PackagesListApp renders 1`] = `
/>
<package-search-stub />
<div>
<section
class="row empty-state text-center"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt=""
class="gl-max-w-full"
role="img"
src="emptyListIllustration"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
<h1
class="h4"
>
There are no packages yet
</h1>
<p>
Learn how to
<b-link-stub
class="gl-link"
event="click"
href="emptyListHelpUrl"
routertag="a"
target="_blank"
>
publish and share your packages
</b-link-stub>
with GitLab.
</p>
<div
class="gl-display-flex gl-flex-wrap gl-justify-content-center"
>
<!---->
<!---->
</div>
</div>
</div>
</section>
</div>
</div>
`;
......@@ -2,22 +2,25 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PackageListApp from '~/packages_and_registries/package_registry/components/list/app.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue';
import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
LIST_QUERY_DEBOUNCE_TIME,
GRAPHQL_PAGE_SIZE,
} from '~/packages_and_registries/package_registry/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import { packagesListQuery } from '../../mock_data';
import { packagesListQuery, packageData, pagination } from '../../mock_data';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
......@@ -39,11 +42,19 @@ describe('PackagesListApp', () => {
const PackageList = {
name: 'package-list',
template: '<div><slot name="empty-state"></slot></div>',
props: OriginalPackageList.props,
};
const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' };
const searchPayload = {
sort: 'VERSION_DESC',
filters: { packageName: 'foo', packageType: 'CONAN' },
};
const findPackageTitle = () => wrapper.findComponent(PackageTitle);
const findSearch = () => wrapper.findComponent(PackageSearch);
const findListComponent = () => wrapper.findComponent(PackageList);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(packagesListQuery()),
......@@ -105,25 +116,55 @@ describe('PackagesListApp', () => {
const resolver = jest.fn().mockResolvedValue(packagesListQuery());
mountComponent({ resolver });
const payload = {
sort: 'VERSION_DESC',
filters: { packageName: 'foo', packageType: 'CONAN' },
};
findSearch().vm.$emit('update', payload);
findSearch().vm.$emit('update', searchPayload);
await waitForDebouncedApollo();
jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME);
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
groupSort: payload.sort,
...payload.filters,
groupSort: searchPayload.sort,
...searchPayload.filters,
}),
);
});
});
describe('list component', () => {
let resolver;
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(packagesListQuery());
mountComponent({ resolver });
return waitForDebouncedApollo();
});
it('exists and has the right props', () => {
expect(findListComponent().props()).toMatchObject({
list: expect.arrayContaining([expect.objectContaining({ id: packageData().id })]),
isLoading: false,
pageInfo: expect.objectContaining({ endCursor: pagination().endCursor }),
});
});
it('when list emits next-page fetches the next set of records', () => {
findListComponent().vm.$emit('next-page');
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pagination().endCursor, first: GRAPHQL_PAGE_SIZE }),
);
});
it('when list emits prev-page fetches the prev set of records', () => {
findListComponent().vm.$emit('prev-page');
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ before: pagination().startCursor, last: GRAPHQL_PAGE_SIZE }),
);
});
});
describe.each`
type | sortType
${PROJECT_RESOURCE_TYPE} | ${'sort'}
......@@ -136,7 +177,7 @@ describe('PackagesListApp', () => {
beforeEach(() => {
provide = { ...defaultProvide, isGroupPage };
resolver = jest.fn().mockResolvedValue(packagesListQuery(type));
resolver = jest.fn().mockResolvedValue(packagesListQuery({ type }));
mountComponent({ provide, resolver });
return waitForDebouncedApollo();
});
......@@ -151,4 +192,40 @@ describe('PackagesListApp', () => {
);
});
});
describe('empty state', () => {
beforeEach(() => {
const resolver = jest.fn().mockResolvedValue(packagesListQuery({ extend: { nodes: [] } }));
mountComponent({ resolver });
return waitForDebouncedApollo();
});
it('generate the correct empty list link', () => {
const link = findListComponent().findComponent(GlLink);
expect(link.attributes('href')).toBe(defaultProvide.emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
expect(findEmptyState().text()).toContain(PackageListApp.i18n.emptyPageTitle);
});
});
describe('filter without results', () => {
beforeEach(async () => {
mountComponent();
await waitForDebouncedApollo();
findSearch().vm.$emit('update', searchPayload);
return nextTick();
});
it('should show specific empty message', () => {
expect(findEmptyState().text()).toContain(PackageListApp.i18n.noResultsTitle);
expect(findEmptyState().text()).toContain(PackageListApp.i18n.widenFilters);
});
});
});
import capitalize from 'lodash/capitalize';
export const packageTags = () => [
{ id: 'gid://gitlab/Packages::Tag/87', name: 'bananas_9', __typename: 'PackageTag' },
{ id: 'gid://gitlab/Packages::Tag/86', name: 'bananas_8', __typename: 'PackageTag' },
......@@ -156,6 +158,15 @@ export const nugetMetadata = () => ({
projectUrl: 'projectUrl',
});
export const pagination = (extend) => ({
endCursor: 'eyJpZCI6IjIwNSIsIm5hbWUiOiJteS9jb21wYW55L2FwcC9teS1hcHAifQ',
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'eyJpZCI6IjI0NyIsIm5hbWUiOiJ2ZXJzaW9uX3Rlc3QxIn0',
__typename: 'PageInfo',
...extend,
});
export const packageDetailsQuery = (extendPackage) => ({
data: {
package: {
......@@ -256,7 +267,7 @@ export const packageDestroyFileMutationError = () => ({
],
});
export const packagesListQuery = (type = 'group') => ({
export const packagesListQuery = ({ type = 'group', extend = {}, extendPagination = {} } = {}) => ({
data: {
[type]: {
packages: {
......@@ -277,9 +288,11 @@ export const packagesListQuery = (type = 'group') => ({
pipelines: { nodes: [] },
},
],
pageInfo: pagination(extendPagination),
__typename: 'PackageConnection',
},
__typename: 'Group',
...extend,
__typename: capitalize(type),
},
},
});
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