Commit 7f166104 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '330846-convert-package-list-page-to-use-apollo-graphql' into 'master'

Connect list app to apollo, enable search

See merge request gitlab-org/gitlab!71280
parents 77299e52 7595ec3c
......@@ -10,10 +10,14 @@ import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
LIST_QUERY_DEBOUNCE_TIME,
} from '~/packages_and_registries/package_registry/constants';
import PackageTitle from './package_title.vue';
// import PackageSearch from './package_search.vue';
import PackageSearch from './package_search.vue';
// import PackageList from './packages_list.vue';
export default {
......@@ -23,28 +27,53 @@ export default {
// GlSprintf,
// PackageList,
PackageTitle,
// PackageSearch,
PackageSearch,
},
inject: ['packageHelpUrl', 'emptyListIllustration', 'emptyListHelpUrl'],
inject: [
'packageHelpUrl',
'emptyListIllustration',
'emptyListHelpUrl',
'isGroupPage',
'fullPath',
],
data() {
return {
filter: [],
sorting: {
sort: 'desc',
orderBy: 'created_at',
},
selectedType: '',
pagination: {},
packages: {},
sort: '',
filters: {},
};
},
apollo: {
packages: {
query: getPackagesQuery,
variables() {
return this.queryVariables;
},
update(data) {
return data[this.graphqlResource].packages;
},
debounce: LIST_QUERY_DEBOUNCE_TIME,
},
},
computed: {
queryVariables() {
return {
isGroupPage: this.isGroupPage,
fullPath: this.fullPath,
sort: this.isGroupPage ? undefined : this.sort,
groupSort: this.isGroupPage ? this.sort : undefined,
packageName: this.filters?.packageName,
packageType: this.filters?.packageType,
};
},
graphqlResource() {
return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
},
packagesCount() {
return 0;
return this.packages?.count;
},
emptySearch() {
return (
this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0
);
hasFilters() {
return this.filters.packageName && this.filters.packageType;
},
emptyStateTitle() {
return this.emptySearch
......@@ -53,19 +82,9 @@ export default {
},
},
mounted() {
const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams);
this.sorting = { ...sorting };
this.filter = [...filters];
this.checkDeleteAlert();
},
methods: {
onPageChanged(page) {
return this.requestPackagesList({ page });
},
onPackageDeleteRequest(item) {
return this.requestDeletePackage(item);
},
checkDeleteAlert() {
const urlParams = new URLSearchParams(window.location.search);
const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
......@@ -76,6 +95,10 @@ export default {
historyReplaceState(cleanUrl);
}
},
handleSearchUpdate({ sort, filters }) {
this.sort = sort;
this.filters = { ...filters };
},
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
......@@ -91,13 +114,13 @@ export default {
<template>
<div>
<package-title :help-url="packageHelpUrl" :count="packagesCount" />
<!-- <package-search @update="requestPackagesList" />
<package-search @update="handleSearchUpdate" />
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
<template #description>
<gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
<gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResultsText">
<template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
......
<script>
import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale';
import { sortableFields } from '~/packages/list/utils';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import {
FILTERED_SEARCH_TERM,
FILTERED_SEARCH_TYPE,
} from '~/packages_and_registries/shared/constants';
import PackageTypeToken from './tokens/package_type_token.vue';
export default {
......@@ -19,21 +23,71 @@ export default {
},
],
components: { RegistrySearch, UrlSync },
inject: ['isGroupPage'],
data() {
return {
filters: [],
sorting: {
orderBy: 'name',
sort: 'desc',
},
mountRegistrySearch: false,
};
},
computed: {
...mapState({
isGroupPage: (state) => state.config.isGroupPage,
sorting: (state) => state.sorting,
filter: (state) => state.filter,
}),
sortableFields() {
return sortableFields(this.isGroupPage);
},
parsedSorting() {
const cleanOrderBy = this.sorting?.orderBy.replace('_at', '');
return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase();
},
parsedFilters() {
const parsed = {
packageName: '',
packageType: undefined,
};
return this.filters.reduce((acc, filter) => {
if (filter.type === FILTERED_SEARCH_TYPE && filter.value?.data) {
return {
...acc,
packageType: filter.value.data.toUpperCase(),
};
}
if (filter.type === FILTERED_SEARCH_TERM) {
return {
...acc,
packageName: `${acc.packageName} ${filter.value.data}`.trim(),
};
}
return acc;
}, parsed);
},
},
mounted() {
const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams);
this.updateSorting(sorting);
this.updateFilters(filters);
this.mountRegistrySearch = true;
this.emitUpdate();
},
methods: {
...mapActions(['setSorting', 'setFilter']),
updateFilters(newValue) {
this.filters = newValue;
},
updateSorting(newValue) {
this.setSorting(newValue);
this.$emit('update');
this.sorting = { ...this.sorting, ...newValue };
},
updateSortingAndEmitUpdate(newValue) {
this.updateSorting(newValue);
this.emitUpdate();
},
emitUpdate() {
this.$emit('update', { sort: this.parsedSorting, filters: this.parsedFilters });
},
},
};
......@@ -43,13 +97,14 @@ export default {
<url-sync>
<template #default="{ updateQuery }">
<registry-search
:filter="filter"
v-if="mountRegistrySearch"
:filter="filters"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSorting"
@filter:changed="setFilter"
@filter:submit="$emit('update')"
@sorting:changed="updateSortingAndEmitUpdate"
@filter:changed="updateFilters"
@filter:submit="emitUpdate"
@query:changed="updateQuery"
/>
</template>
......
......@@ -89,3 +89,7 @@ export const YARN_PACKAGE_MANAGER = 'yarn';
export const PROJECT_PACKAGE_ENDPOINT_TYPE = 'project';
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;
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
query getPackages(
$fullPath: ID!
$isGroupPage: Boolean!
$sort: PackageSort
$groupSort: PackageGroupSort
$packageName: String
$packageType: PackageTypeEnum
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
packages(sort: $sort, packageName: $packageName, packageType: $packageType) {
count
nodes {
...PackageData
}
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) {
count
nodes {
...PackageData
}
}
}
}
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
import PackagesListApp from '../components/list/app.vue';
Vue.use(Translate);
......@@ -7,10 +8,14 @@ Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-packages-list');
const isGroupPage = el.dataset.pageType === 'groups';
return new Vue({
el,
apolloProvider,
provide: {
...el.dataset,
isGroupPage,
},
render(createElement) {
return createElement(PackagesListApp);
......
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
export const FILTERED_SEARCH_TYPE = 'type';
......@@ -41,6 +41,7 @@ module PackagesHelper
def packages_list_data(type, resource)
{
resource_id: resource.id,
full_path: resource.full_path,
page_type: type,
empty_list_help_url: help_page_path('user/packages/package_registry/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg'),
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list_app renders 1`] = `
exports[`PackagesListApp renders 1`] = `
<div>
<package-title-stub
count="0"
count="2"
helpurl="packageHelpUrl"
/>
<package-search-stub />
</div>
`;
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
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 {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
LIST_QUERY_DEBOUNCE_TIME,
} from '~/packages_and_registries/package_registry/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import * as packageUtils from '~/packages_and_registries/shared/utils';
import { packagesListQuery } from '../../mock_data';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
describe('packages_list_app', () => {
const localVue = createLocalVue();
describe('PackagesListApp', () => {
let wrapper;
let apolloProvider;
const defaultProvide = {
packageHelpUrl: 'packageHelpUrl',
emptyListIllustration: 'emptyListIllustration',
emptyListHelpUrl: 'emptyListHelpUrl',
isGroupPage: true,
fullPath: 'gitlab-org',
};
const PackageList = {
name: 'package-list',
......@@ -18,9 +43,21 @@ describe('packages_list_app', () => {
const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' };
const findPackageTitle = () => wrapper.findComponent(PackageTitle);
const findSearch = () => wrapper.findComponent(PackageSearch);
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(packagesListQuery()),
provide = defaultProvide,
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [[getPackagesQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
const mountComponent = () => {
wrapper = shallowMountExtended(PackageListApp, {
localVue,
apolloProvider,
provide,
stubs: {
GlEmptyState,
GlLoadingIcon,
......@@ -28,30 +65,90 @@ describe('packages_list_app', () => {
GlSprintf,
GlLink,
},
provide: {
packageHelpUrl: 'packageHelpUrl',
emptyListIllustration: 'emptyListIllustration',
emptyListHelpUrl: 'emptyListHelpUrl',
},
});
};
beforeEach(() => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
});
afterEach(() => {
wrapper.destroy();
});
it('renders', () => {
const waitForDebouncedApollo = () => {
jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME);
return waitForPromises();
};
it('renders', async () => {
mountComponent();
await waitForDebouncedApollo();
expect(wrapper.element).toMatchSnapshot();
});
it('has a package title', () => {
it('has a package title', async () => {
mountComponent();
await waitForDebouncedApollo();
expect(findPackageTitle().exists()).toBe(true);
expect(findPackageTitle().props('count')).toBe(2);
});
describe('search component', () => {
it('exists', () => {
mountComponent();
expect(findSearch().exists()).toBe(true);
});
it('on update triggers a new query with updated values', async () => {
const resolver = jest.fn().mockResolvedValue(packagesListQuery());
mountComponent({ resolver });
const payload = {
sort: 'VERSION_DESC',
filters: { packageName: 'foo', packageType: 'CONAN' },
};
findSearch().vm.$emit('update', payload);
await waitForDebouncedApollo();
jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME);
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
groupSort: payload.sort,
...payload.filters,
}),
);
});
});
describe.each`
type | sortType
${PROJECT_RESOURCE_TYPE} | ${'sort'}
${GROUP_RESOURCE_TYPE} | ${'groupSort'}
`('$type query', ({ type, sortType }) => {
let provide;
let resolver;
const isGroupPage = type === GROUP_RESOURCE_TYPE;
beforeEach(() => {
provide = { ...defaultProvide, isGroupPage };
resolver = jest.fn().mockResolvedValue(packagesListQuery(type));
mountComponent({ provide, resolver });
return waitForDebouncedApollo();
});
it('succeeds', () => {
expect(findPackageTitle().props('count')).toBe(2);
});
it('calls the resolver with the right parameters', () => {
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ isGroupPage, [sortType]: '' }),
);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { sortableFields } from '~/packages/list/utils';
import component from '~/packages_and_registries/package_registry/components/list/package_search.vue';
import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
const localVue = createLocalVue();
localVue.use(Vuex);
jest.mock('~/packages_and_registries/shared/utils');
useMockLocationHelper();
describe('Package Search', () => {
let wrapper;
let store;
const defaultQueryParamsMock = {
filters: ['foo'],
sorting: { sort: 'desc' },
};
const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
const findUrlSync = () => wrapper.findComponent(UrlSync);
const createStore = (isGroupPage) => {
const state = {
config: {
const mountComponent = (isGroupPage = false) => {
wrapper = shallowMountExtended(component, {
provide() {
return {
isGroupPage,
},
sorting: {
orderBy: 'version',
sort: 'desc',
},
filter: [],
};
store = new Vuex.Store({
state,
});
store.dispatch = jest.fn();
};
const mountComponent = (isGroupPage = false) => {
createStore(isGroupPage);
wrapper = shallowMount(component, {
localVue,
store,
},
stubs: {
UrlSync,
},
});
};
beforeEach(() => {
extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('has a registry search component', () => {
it('has a registry search component', async () => {
mountComponent();
await nextTick();
expect(findRegistrySearch().exists()).toBe(true);
expect(findRegistrySearch().props()).toMatchObject({
filter: store.state.filter,
sorting: store.state.sorting,
tokens: expect.arrayContaining([
expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }),
]),
sortableFields: sortableFields(),
});
it('registry search is mounted after mount', async () => {
mountComponent();
expect(findRegistrySearch().exists()).toBe(false);
});
it('has a UrlSync component', () => {
mountComponent();
expect(findUrlSync().exists()).toBe(true);
});
it.each`
isGroupPage | page
${false} | ${'project'}
${true} | ${'group'}
`('in a $page page binds the right props', ({ isGroupPage }) => {
`('in a $page page binds the right props', async ({ isGroupPage }) => {
mountComponent(isGroupPage);
await nextTick();
expect(findRegistrySearch().props()).toMatchObject({
filter: store.state.filter,
sorting: store.state.sorting,
tokens: expect.arrayContaining([
expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }),
]),
......@@ -81,48 +81,85 @@ describe('Package Search', () => {
});
});
it('on sorting:changed emits update event and calls vuex setSorting', () => {
it('on sorting:changed emits update event and update internal sort', async () => {
const payload = { sort: 'foo' };
mountComponent();
await nextTick();
findRegistrySearch().vm.$emit('sorting:changed', payload);
expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload);
expect(wrapper.emitted('update')).toEqual([[]]);
await nextTick();
expect(findRegistrySearch().props('sorting')).toEqual({ sort: 'foo', orderBy: 'name' });
// there is always a first call on mounted that emits up default values
expect(wrapper.emitted('update')[1]).toEqual([
{
filters: {
packageName: '',
packageType: undefined,
},
sort: 'NAME_FOO',
},
]);
});
it('on filter:changed calls vuex setFilter', () => {
it('on filter:changed updates the filters', async () => {
const payload = ['foo'];
mountComponent();
await nextTick();
findRegistrySearch().vm.$emit('filter:changed', payload);
expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload);
await nextTick();
expect(findRegistrySearch().props('filter')).toEqual(['foo']);
});
it('on filter:submit emits update event', () => {
it('on filter:submit emits update event', async () => {
mountComponent();
findRegistrySearch().vm.$emit('filter:submit');
await nextTick();
expect(wrapper.emitted('update')).toEqual([[]]);
});
it('has a UrlSync component', () => {
mountComponent();
findRegistrySearch().vm.$emit('filter:submit');
expect(findUrlSync().exists()).toBe(true);
expect(wrapper.emitted('update')[1]).toEqual([
{
filters: {
packageName: '',
packageType: undefined,
},
sort: 'NAME_DESC',
},
]);
});
it('on query:changed calls updateQuery from UrlSync', () => {
it('on query:changed calls updateQuery from UrlSync', async () => {
jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
mountComponent();
await nextTick();
findRegistrySearch().vm.$emit('query:changed');
expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
});
it('sets the component sorting and filtering based on the querystring', async () => {
mountComponent();
await nextTick();
expect(getQueryParams).toHaveBeenCalled();
expect(findRegistrySearch().props()).toMatchObject({
filter: defaultQueryParamsMock.filters,
sorting: defaultQueryParamsMock.sorting,
});
});
});
......@@ -249,3 +249,27 @@ export const packageDestroyFileMutationError = () => ({
},
],
});
export const packagesListQuery = (type = 'group') => ({
data: {
[type]: {
packages: {
count: 2,
nodes: [
{
__typename: 'Package',
id: 'gid://gitlab/Packages::Package/247',
name: 'version_test1',
},
{
__typename: 'Package',
id: 'gid://gitlab/Packages::Package/246',
name: 'version_test1',
},
],
__typename: 'PackageConnection',
},
__typename: 'Group',
},
},
});
......@@ -260,4 +260,34 @@ RSpec.describe PackagesHelper do
end
end
end
describe '#packages_list_data' do
let_it_be(:resource) { project }
let_it_be(:type) { 'project' }
let(:expected_result) do
{
resource_id: resource.id,
full_path: resource.full_path,
page_type: type
}
end
subject(:result) { helper.packages_list_data(type, resource) }
context 'at a project level' do
it 'populates presenter data' do
expect(result).to match(hash_including(expected_result))
end
end
context 'at a group level' do
let_it_be(:resource) { create(:group) }
let_it_be(:type) { 'group' }
it 'populates presenter data' do
expect(result).to match(hash_including(expected_result))
end
end
end
end
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