Commit ccb81a57 authored by Mark Florian's avatar Mark Florian

Merge branch '324864-make-it-easy-to-share-a-filtered-view-of-the-registry-2' into 'master'

Connect Registries searches to URL

See merge request gitlab-org/gitlab!57251
parents b0de07d0 269fbf52
......@@ -2,6 +2,7 @@
import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import getTableHeaders from '../utils';
import PackageTypeToken from './tokens/package_type_token.vue';
......@@ -16,7 +17,7 @@ export default {
operators: [{ value: '=', description: __('is'), default: 'true' }],
},
],
components: { RegistrySearch },
components: { RegistrySearch, UrlSync },
computed: {
...mapState({
isGroupPage: (state) => state.config.isGroupPage,
......@@ -38,13 +39,18 @@ export default {
</script>
<template>
<registry-search
:filter="filter"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSorting"
@filter:changed="setFilter"
@filter:submit="$emit('update')"
/>
<url-sync>
<template #default="{ updateQuery }">
<registry-search
:filter="filter"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSorting"
@filter:changed="setFilter"
@filter:submit="$emit('update')"
@query:changed="updateQuery"
/>
</template>
</url-sync>
</template>
......@@ -6,6 +6,7 @@ import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
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 { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageSearch from './package_search.vue';
import PackageTitle from './package_title.vue';
......@@ -42,11 +43,21 @@ export default {
},
},
mounted() {
const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams);
this.setSorting(sorting);
this.setFilter(filters);
this.requestPackagesList();
this.checkDeleteAlert();
},
methods: {
...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']),
...mapActions([
'requestPackagesList',
'requestDeletePackage',
'setSelectedType',
'setSorting',
'setFilter',
]),
onPageChanged(page) {
return this.requestPackagesList({ page });
},
......
......@@ -7,3 +7,23 @@ export const keyValueToFilterToken = (type, data) => ({ type, value: { data } })
export const searchArrayToFilterTokens = (search) =>
search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s));
export const extractFilterAndSorting = (queryObject) => {
const { type, search, sort, orderBy } = queryObject;
const filters = [];
const sorting = {};
if (type) {
filters.push(keyValueToFilterToken('type', type));
}
if (search) {
filters.push(...searchArrayToFilterTokens(search));
}
if (sort) {
sorting.sort = sort;
}
if (orderBy) {
sorting.orderBy = orderBy;
}
return { filters, sorting };
};
......@@ -12,6 +12,7 @@ import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue';
......@@ -82,6 +83,9 @@ export default {
searchConfig: SORT_FIELDS,
apollo: {
baseImages: {
skip() {
return !this.fetchBaseQuery;
},
query: getContainerRepositoriesQuery,
variables() {
return this.queryVariables;
......@@ -125,15 +129,19 @@ export default {
sorting: { orderBy: 'UPDATED', sort: 'desc' },
name: null,
mutationLoading: false,
fetchBaseQuery: false,
fetchAdditionalDetails: false,
};
},
computed: {
images() {
return this.baseImages.map((image, index) => ({
...image,
...get(this.additionalDetails, index, {}),
}));
if (this.baseImages) {
return this.baseImages.map((image, index) => ({
...image,
...get(this.additionalDetails, index, {}),
}));
}
return [];
},
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
......@@ -172,8 +180,15 @@ export default {
},
},
mounted() {
const { sorting, filters } = extractFilterAndSorting(this.$route.query);
this.filter = [...filters];
this.name = filters[0]?.value.data;
this.sorting = { ...this.sorting, ...sorting };
// If the two graphql calls - which are not batched - resolve togheter we will have a race
// condition when apollo sets the cache, with this we give the 'base' call an headstart
this.fetchBaseQuery = true;
setTimeout(() => {
this.fetchAdditionalDetails = true;
}, 200);
......@@ -245,6 +260,9 @@ export default {
const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data;
},
updateUrlQueryString(query) {
this.$router.push({ query });
},
},
};
</script>
......@@ -304,6 +322,7 @@ export default {
@sorting:changed="updateSorting"
@filter:changed="filter = $event"
@filter:submit="doFilter"
@query:changed="updateUrlQueryString"
/>
<div v-if="isLoading" class="gl-mt-5">
......
---
title: Connect Registries searches to URL
merge_request: 57251
author:
type: added
......@@ -7,6 +7,8 @@ import PackageSearch from '~/packages/list/components/package_search.vue';
import PackageListApp from '~/packages/list/components/packages_list_app.vue';
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 * as packageUtils from '~/packages_and_registries/shared/utils';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
......@@ -61,6 +63,7 @@ describe('packages_list_app', () => {
beforeEach(() => {
createStore();
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
});
afterEach(() => {
......@@ -72,25 +75,6 @@ describe('packages_list_app', () => {
expect(wrapper.element).toMatchSnapshot();
});
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');
});
});
it('call requestPackagesList on page:changed', () => {
mountComponent();
store.dispatch.mockClear();
......@@ -108,10 +92,75 @@ describe('packages_list_app', () => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo');
});
it('does not call requestPackagesList two times on render', () => {
it('does call requestPackagesList only one time on render', () => {
mountComponent();
expect(store.dispatch).toHaveBeenCalledTimes(1);
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', () => {
......
......@@ -4,6 +4,7 @@ import component from '~/packages/list/components/package_search.vue';
import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue';
import getTableHeaders from '~/packages/list/utils';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -12,7 +13,8 @@ describe('Package Search', () => {
let wrapper;
let store;
const findRegistrySearch = () => wrapper.find(RegistrySearch);
const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
const findUrlSync = () => wrapper.findComponent(UrlSync);
const createStore = (isGroupPage) => {
const state = {
......@@ -37,6 +39,9 @@ describe('Package Search', () => {
wrapper = shallowMount(component, {
localVue,
store,
stubs: {
UrlSync,
},
});
};
......@@ -104,4 +109,20 @@ describe('Package Search', () => {
expect(wrapper.emitted('update')).toEqual([[]]);
});
it('has a UrlSync component', () => {
mountComponent();
expect(findUrlSync().exists()).toBe(true);
});
it('on query:changed calls updateQuery from UrlSync', () => {
jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
mountComponent();
findRegistrySearch().vm.$emit('query:changed');
expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
});
});
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import {
getQueryParams,
keyValueToFilterToken,
searchArrayToFilterTokens,
extractFilterAndSorting,
} from '~/packages_and_registries/shared/utils';
describe('Packages And Registries shared utils', () => {
......@@ -27,9 +29,31 @@ describe('Packages And Registries shared utils', () => {
const search = ['one', 'two'];
expect(searchArrayToFilterTokens(search)).toStrictEqual([
{ type: 'filtered-search-term', value: { data: 'one' } },
{ type: 'filtered-search-term', value: { data: 'two' } },
{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } },
{ type: FILTERED_SEARCH_TERM, value: { data: 'two' } },
]);
});
});
describe('extractFilterAndSorting', () => {
it.each`
search | type | sort | orderBy | result
${['one']} | ${'myType'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${['one']} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${[]} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }}
`(
'returns sorting and filters objects in the correct form',
({ search, type, sort, orderBy, result }) => {
const queryObject = {
search,
type,
sort,
orderBy,
};
expect(extractFilterAndSorting(queryObject)).toStrictEqual(result);
},
);
});
});
import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -61,7 +62,7 @@ describe('List Page', () => {
const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers();
await waitForPromises();
await wrapper.vm.$nextTick();
await nextTick();
};
const mountComponent = ({
......@@ -70,6 +71,7 @@ describe('List Page', () => {
detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock),
mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
config = { isGroupPage: false },
query = {},
} = {}) => {
localVue.use(VueApollo);
......@@ -96,6 +98,7 @@ describe('List Page', () => {
$toast,
$route: {
name: 'foo',
query,
},
...mocks,
},
......@@ -159,9 +162,11 @@ describe('List Page', () => {
});
describe('isLoading is true', () => {
it('shows the skeleton loader', () => {
it('shows the skeleton loader', async () => {
mountComponent();
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true);
});
......@@ -177,9 +182,11 @@ describe('List Page', () => {
expect(findCliCommands().exists()).toBe(false);
});
it('title has the metadataLoading props set to true', () => {
it('title has the metadataLoading props set to true', async () => {
mountComponent();
await nextTick();
expect(findRegistryHeader().props('metadataLoading')).toBe(true);
});
});
......@@ -312,7 +319,7 @@ describe('List Page', () => {
await selectImageForDeletion();
findDeleteImage().vm.$emit('success');
await wrapper.vm.$nextTick();
await nextTick();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
......@@ -328,7 +335,7 @@ describe('List Page', () => {
await selectImageForDeletion();
findDeleteImage().vm.$emit('error');
await wrapper.vm.$nextTick();
await nextTick();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
......@@ -349,7 +356,7 @@ describe('List Page', () => {
findRegistrySearch().vm.$emit('filter:submit');
await wrapper.vm.$nextTick();
await nextTick();
};
it('has a search box element', async () => {
......@@ -374,7 +381,7 @@ describe('List Page', () => {
await waitForApolloRequestRender();
findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' });
await wrapper.vm.$nextTick();
await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' }));
});
......@@ -417,7 +424,7 @@ describe('List Page', () => {
await waitForApolloRequestRender();
findImageList().vm.$emit('prev-page');
await wrapper.vm.$nextTick();
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ before: pageInfo.startCursor }),
......@@ -437,7 +444,7 @@ describe('List Page', () => {
await waitForApolloRequestRender();
findImageList().vm.$emit('next-page');
await wrapper.vm.$nextTick();
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pageInfo.endCursor }),
......@@ -458,11 +465,10 @@ describe('List Page', () => {
expect(findDeleteModal().exists()).toBe(true);
});
it('contains a description with the path of the item to delete', () => {
it('contains a description with the path of the item to delete', async () => {
findImageList().vm.$emit('delete', { path: 'foo' });
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain('foo');
});
await nextTick();
expect(findDeleteModal().html()).toContain('foo');
});
});
......@@ -498,4 +504,60 @@ describe('List Page', () => {
testTrackingCall('confirm_delete');
});
});
describe('url query string handling', () => {
const defaultQueryParams = {
search: [1, 2],
sort: 'asc',
orderBy: 'CREATED',
};
const queryChangePayload = 'foo';
it('query:updated event pushes the new query to the router', async () => {
const push = jest.fn();
mountComponent({ mocks: { $router: { push } } });
await nextTick();
findRegistrySearch().vm.$emit('query:changed', queryChangePayload);
expect(push).toHaveBeenCalledWith({ query: queryChangePayload });
});
it('graphql API call has the variables set from the URL', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ query: defaultQueryParams, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
name: 1,
sort: 'CREATED_ASC',
}),
);
});
it.each`
sort | orderBy | search | payload
${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }}
${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }}
${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }}
${undefined} | ${undefined} | ${undefined} | ${{}}
${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }}
${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }}
${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }}
${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }}
`(
'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload',
async ({ sort, orderBy, search, payload }) => {
const resolver = jest.fn().mockResolvedValue({ sort, orderBy });
mountComponent({ query: { sort, orderBy, search }, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload));
},
);
});
});
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