Commit 04f25815 authored by Natalia Tepluhina's avatar Natalia Tepluhina

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

Package list to graphql: add feature flag

See merge request gitlab-org/gitlab!70598
parents 8c1308b3 175c9c9f
<script> <script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; /*
import { mapActions, mapState } from 'vuex'; * The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs
* 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 createFlash from '~/flash'; import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils'; import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
...@@ -8,84 +12,54 @@ import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; ...@@ -8,84 +12,54 @@ import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import PackageList from './packages_list.vue'; import PackageTitle from './package_title.vue';
// import PackageSearch from './package_search.vue';
// import PackageList from './packages_list.vue';
export default { export default {
components: { components: {
GlEmptyState, // GlEmptyState,
GlLink, // GlLink,
GlSprintf, // GlSprintf,
PackageList, // PackageList,
PackageTitle: () => PackageTitle,
import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'), // PackageSearch,
PackageSearch: () =>
import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'),
InfrastructureTitle: () =>
import(
/* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'
),
InfrastructureSearch: () =>
import(
/* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'
),
}, },
inject: { inject: ['packageHelpUrl', 'emptyListIllustration', 'emptyListHelpUrl'],
titleComponent: { data() {
from: 'titleComponent', return {
default: 'PackageTitle', filter: [],
}, sorting: {
searchComponent: { sort: 'desc',
from: 'searchComponent', orderBy: 'created_at',
default: 'PackageSearch', },
}, selectedType: '',
emptyPageTitle: { pagination: {},
from: 'emptyPageTitle', };
default: s__('PackageRegistry|There are no packages yet'),
},
noResultsText: {
from: 'noResultsText',
default: s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
},
}, },
computed: { computed: {
...mapState({ packagesCount() {
emptyListIllustration: (state) => state.config.emptyListIllustration, return 0;
emptyListHelpUrl: (state) => state.config.emptyListHelpUrl, },
filter: (state) => state.filter,
selectedType: (state) => state.selectedType,
packageHelpUrl: (state) => state.config.packageHelpUrl,
packagesCount: (state) => state.pagination?.total,
}),
emptySearch() { emptySearch() {
return ( return (
this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0 this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0
); );
}, },
emptyStateTitle() { emptyStateTitle() {
return this.emptySearch return this.emptySearch
? this.emptyPageTitle ? this.$options.i18n.emptyPageTitle
: s__('PackageRegistry|Sorry, your filter produced no results'); : this.$options.i18n.noResultsTitle;
}, },
}, },
mounted() { mounted() {
const queryParams = getQueryParams(window.document.location.search); const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams); const { sorting, filters } = extractFilterAndSorting(queryParams);
this.setSorting(sorting); this.sorting = { ...sorting };
this.setFilter(filters); this.filter = [...filters];
this.requestPackagesList();
this.checkDeleteAlert(); this.checkDeleteAlert();
}, },
methods: { methods: {
...mapActions([
'requestPackagesList',
'requestDeletePackage',
'setSelectedType',
'setSorting',
'setFilter',
]),
onPageChanged(page) { onPageChanged(page) {
return this.requestPackagesList({ page }); return this.requestPackagesList({ page });
}, },
...@@ -105,21 +79,26 @@ export default { ...@@ -105,21 +79,26 @@ export default {
}, },
i18n: { i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
emptyPageTitle: s__('PackageRegistry|There are no packages yet'),
noResultsTitle: s__('PackageRegistry|Sorry, your filter produced no results'),
noResultsText: s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" /> <package-title :help-url="packageHelpUrl" :count="packagesCount" />
<component :is="searchComponent" @update="requestPackagesList" /> <!-- <package-search @update="requestPackagesList" />
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state> <template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
<template #description> <template #description>
<gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" /> <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="noResultsText"> <gl-sprintf v-else :message="$options.i18n.noResultsText">
<template #noPackagesLink="{ content }"> <template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
</template> </template>
...@@ -127,6 +106,6 @@ export default { ...@@ -127,6 +106,6 @@ export default {
</template> </template>
</gl-empty-state> </gl-empty-state>
</template> </template>
</package-list> </package-list> -->
</div> </div>
</template> </template>
import Vue from 'vue'; import Vue from 'vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import PackagesListApp from '../components/list/packages_list_app.vue'; import PackagesListApp from '../components/list/app.vue';
Vue.use(Translate); Vue.use(Translate);
...@@ -9,6 +9,9 @@ export default () => { ...@@ -9,6 +9,9 @@ export default () => {
return new Vue({ return new Vue({
el, el,
provide: {
...el.dataset,
},
render(createElement) { render(createElement) {
return createElement(PackagesListApp); return createElement(PackagesListApp);
}, },
......
import initPackageList from '~/packages/list/packages_list_app_bundle'; (async function packageApp() {
if (window.gon.features.packageListApollo) {
const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
if (document.getElementById('js-vue-packages-list')) { newPackageList.default();
initPackageList(); } else {
} const packageList = await import('~/packages/list/packages_list_app_bundle');
packageList.default();
}
})();
import initPackageList from '~/packages/list/packages_list_app_bundle'; (async function packageApp() {
if (window.gon.features.packageListApollo) {
const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
initPackageList(); newPackageList.default();
} else {
const packageList = await import('~/packages/list/packages_list_app_bundle');
packageList.default();
}
})();
...@@ -6,6 +6,10 @@ module Groups ...@@ -6,6 +6,10 @@ module Groups
feature_category :package_registry feature_category :package_registry
before_action do
push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml)
end
private private
def verify_packages_enabled! def verify_packages_enabled!
......
...@@ -7,6 +7,10 @@ module Projects ...@@ -7,6 +7,10 @@ module Projects
feature_category :package_registry feature_category :package_registry
before_action do
push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml)
end
def show def show
@package = project.packages.find(params[:id]) @package = project.packages.find(params[:id])
end end
......
---
name: package_list_apollo
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70598
rollout_issue_url:
milestone: '14.3'
type: development
group: group::package
default_enabled: false
...@@ -28,6 +28,10 @@ RSpec.describe 'Group Packages' do ...@@ -28,6 +28,10 @@ RSpec.describe 'Group Packages' do
context 'when feature is available', :js do context 'when feature is available', :js do
before do before do
# we are simply setting the featrure flag to false because the new UI has nothing to test yet
# when the refactor is complete or almost complete we will turn on the feature tests
# see https://gitlab.com/gitlab-org/gitlab/-/issues/330846 for status of this work
stub_feature_flags(package_list_apollo: false)
visit_group_packages visit_group_packages
end end
......
...@@ -27,6 +27,10 @@ RSpec.describe 'Packages' do ...@@ -27,6 +27,10 @@ RSpec.describe 'Packages' do
context 'when feature is available', :js do context 'when feature is available', :js do
before do before do
# we are simply setting the featrure flag to false because the new UI has nothing to test yet
# when the refactor is complete or almost complete we will turn on the feature tests
# see https://gitlab.com/gitlab-org/gitlab/-/issues/330846 for status of this work
stub_feature_flags(package_list_apollo: false)
visit_project_packages visit_project_packages
end end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list_app renders 1`] = `
<div>
<package-title-stub
count="0"
helpurl="packageHelpUrl"
/>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list_app renders 1`] = `
<div>
<div
help-url="foo"
/>
<div />
<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="helpSvg"
/>
</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="helpUrl"
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>
`;
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
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 * as packageUtils from '~/packages_and_registries/shared/utils';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
describe('packages_list_app', () => {
let wrapper;
const PackageList = {
name: 'package-list',
template: '<div><slot name="empty-state"></slot></div>',
};
const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' };
const findPackageTitle = () => wrapper.findComponent(PackageTitle);
const mountComponent = () => {
wrapper = shallowMountExtended(PackageListApp, {
stubs: {
GlEmptyState,
GlLoadingIcon,
PackageList,
GlSprintf,
GlLink,
},
provide: {
packageHelpUrl: 'packageHelpUrl',
emptyListIllustration: 'emptyListIllustration',
emptyListHelpUrl: 'emptyListHelpUrl',
},
});
};
beforeEach(() => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
});
afterEach(() => {
wrapper.destroy();
});
it('renders', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('has a package title', () => {
mountComponent();
expect(findPackageTitle().exists()).toBe(true);
});
});
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper';
import createFlash from '~/flash';
import * as commonUtils from '~/lib/utils/common_utils';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import PackageListApp from '~/packages_and_registries/package_registry/components/list/packages_list_app.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import * as packageUtils from '~/packages_and_registries/shared/utils';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('packages_list_app', () => {
let wrapper;
let store;
const PackageList = {
name: 'package-list',
template: '<div><slot name="empty-state"></slot></div>',
};
const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' };
// we need to manually stub dynamic imported components because shallowMount is not able to stub them automatically. See: https://github.com/vuejs/vue-test-utils/issues/1279
const PackageSearch = { name: 'PackageSearch', template: '<div></div>' };
const PackageTitle = { name: 'PackageTitle', template: '<div></div>' };
const InfrastructureTitle = { name: 'InfrastructureTitle', template: '<div></div>' };
const InfrastructureSearch = { name: 'InfrastructureSearch', template: '<div></div>' };
const emptyListHelpUrl = 'helpUrl';
const findEmptyState = () => wrapper.find(GlEmptyState);
const findListComponent = () => wrapper.find(PackageList);
const findPackageSearch = () => wrapper.find(PackageSearch);
const findPackageTitle = () => wrapper.find(PackageTitle);
const findInfrastructureTitle = () => wrapper.find(InfrastructureTitle);
const findInfrastructureSearch = () => wrapper.find(InfrastructureSearch);
const createStore = (filter = []) => {
store = new Vuex.Store({
state: {
isLoading: false,
config: {
resourceId: 'project_id',
emptyListIllustration: 'helpSvg',
emptyListHelpUrl,
packageHelpUrl: 'foo',
},
filter,
},
});
store.dispatch = jest.fn();
};
const mountComponent = (provide) => {
wrapper = shallowMount(PackageListApp, {
localVue,
store,
stubs: {
GlEmptyState,
GlLoadingIcon,
PackageList,
GlSprintf,
GlLink,
PackageSearch,
PackageTitle,
InfrastructureTitle,
InfrastructureSearch,
},
provide,
});
};
beforeEach(() => {
createStore();
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
});
afterEach(() => {
wrapper.destroy();
});
it('renders', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('call requestPackagesList on page:changed', () => {
mountComponent();
store.dispatch.mockClear();
const list = findListComponent();
list.vm.$emit('page:changed', 1);
expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 });
});
it('call requestDeletePackage on package:delete', () => {
mountComponent();
const list = findListComponent();
list.vm.$emit('package:delete', 'foo');
expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo');
});
it('does call requestPackagesList only one time on render', () => {
mountComponent();
expect(store.dispatch).toHaveBeenCalledTimes(3);
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object));
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array));
expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList');
});
describe('url query string handling', () => {
const defaultQueryParamsMock = {
search: [1, 2],
type: 'npm',
sort: 'asc',
orderBy: 'created',
};
it('calls setSorting with the query string based sorting', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', {
orderBy: defaultQueryParamsMock.orderBy,
sort: defaultQueryParamsMock.sort,
});
});
it('calls setFilter with the query string based filters', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [
{ type: 'type', value: { data: defaultQueryParamsMock.type } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } },
]);
});
it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => {
jest
.spyOn(packageUtils, 'extractFilterAndSorting')
.mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } });
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' });
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']);
});
});
describe('empty state', () => {
it('generate the correct empty list link', () => {
mountComponent();
const link = findListComponent().find(GlLink);
expect(link.attributes('href')).toBe(emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
mountComponent();
const heading = findEmptyState().find('h1');
expect(heading.text()).toBe('There are no packages yet');
});
});
describe('filter without results', () => {
beforeEach(() => {
createStore([{ type: 'something' }]);
mountComponent();
});
it('should show specific empty message', () => {
expect(findEmptyState().text()).toContain('Sorry, your filter produced no results');
expect(findEmptyState().text()).toContain(
'To widen your search, change or remove the filters above',
);
});
});
describe('Package Search', () => {
it('exists', () => {
mountComponent();
expect(findPackageSearch().exists()).toBe(true);
});
it('on update fetches data from the store', () => {
mountComponent();
store.dispatch.mockClear();
findPackageSearch().vm.$emit('update');
expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
});
});
describe('Infrastructure config', () => {
it('defaults to package registry components', () => {
mountComponent();
expect(findPackageSearch().exists()).toBe(true);
expect(findPackageTitle().exists()).toBe(true);
expect(findInfrastructureTitle().exists()).toBe(false);
expect(findInfrastructureSearch().exists()).toBe(false);
});
it('mount different component based on the provided values', () => {
mountComponent({
titleComponent: 'InfrastructureTitle',
searchComponent: 'InfrastructureSearch',
});
expect(findPackageSearch().exists()).toBe(false);
expect(findPackageTitle().exists()).toBe(false);
expect(findInfrastructureTitle().exists()).toBe(true);
expect(findInfrastructureSearch().exists()).toBe(true);
});
});
describe('delete alert handling', () => {
const originalLocation = window.location.href;
const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`;
beforeEach(() => {
createStore();
jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {});
setWindowLocation(search);
});
afterEach(() => {
setWindowLocation(originalLocation);
});
it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
mountComponent();
expect(createFlash).toHaveBeenCalledWith({
message: DELETE_PACKAGE_SUCCESS_MESSAGE,
type: 'notice',
});
});
it('calls historyReplaceState with a clean url', () => {
mountComponent();
expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation);
});
it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
setWindowLocation('?');
mountComponent();
expect(createFlash).not.toHaveBeenCalled();
expect(commonUtils.historyReplaceState).not.toHaveBeenCalled();
});
});
});
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