Commit 8c37c23f authored by Mark Florian's avatar Mark Florian

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

Add query string utilities to package and registries

See merge request gitlab-org/gitlab!57084
parents b121d0ea ff899b03
...@@ -5,6 +5,7 @@ import createFlash from '~/flash'; ...@@ -5,6 +5,7 @@ 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';
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 { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageSearch from './package_search.vue'; import PackageSearch from './package_search.vue';
import PackageTitle from './package_title.vue'; import PackageTitle from './package_title.vue';
...@@ -30,7 +31,7 @@ export default { ...@@ -30,7 +31,7 @@ export default {
}), }),
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
); );
}, },
......
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
import { queryToObject } from '~/lib/utils/url_utility';
import { FILTERED_SEARCH_TERM } from './constants';
export const getQueryParams = (query) => queryToObject(query, { gatherArrays: true });
export const keyValueToFilterToken = (type, data) => ({ type, value: { data } });
export const searchArrayToFilterTokens = (search) =>
search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s));
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
import { get } from 'lodash'; import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue'; import DeleteImage from '../components/delete_image.vue';
...@@ -241,7 +242,7 @@ export default { ...@@ -241,7 +242,7 @@ export default {
}; };
}, },
doFilter() { doFilter() {
const search = this.filter.find((i) => i.type === 'filtered-search-term'); const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data; this.name = search?.value?.data;
}, },
}, },
......
<script> <script>
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
const ASCENDING_ORDER = 'asc'; const ASCENDING_ORDER = 'asc';
const DESCENDING_ORDER = 'desc'; const DESCENDING_ORDER = 'desc';
...@@ -45,18 +46,60 @@ export default { ...@@ -45,18 +46,60 @@ export default {
isSortAscending() { isSortAscending() {
return this.sorting.sort === ASCENDING_ORDER; return this.sorting.sort === ASCENDING_ORDER;
}, },
baselineQueryStringFilters() {
return this.tokens.reduce((acc, curr) => {
acc[curr.type] = '';
return acc;
}, {});
},
}, },
methods: { methods: {
generateQueryData({ sorting = {}, filter = [] } = {}) {
// Ensure that we clean up the query when we remove a token from the search
const result = { ...this.baselineQueryStringFilters, ...sorting, search: [] };
filter.forEach((f) => {
if (f.type === FILTERED_SEARCH_TERM) {
result.search.push(f.value.data);
} else {
result[f.type] = f.value.data;
}
});
return result;
},
onDirectionChange() { onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER; const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
const newQueryString = this.generateQueryData({
sorting: { ...this.sorting, sort },
filter: this.filter,
});
this.$emit('sorting:changed', { sort }); this.$emit('sorting:changed', { sort });
this.$emit('query:changed', newQueryString);
}, },
onSortItemClick(item) { onSortItemClick(item) {
const newQueryString = this.generateQueryData({
sorting: { ...this.sorting, orderBy: item },
filter: this.filter,
});
this.$emit('sorting:changed', { orderBy: item }); this.$emit('sorting:changed', { orderBy: item });
this.$emit('query:changed', newQueryString);
},
submitSearch() {
const newQueryString = this.generateQueryData({
sorting: this.sorting,
filter: this.filter,
});
this.$emit('filter:submit');
this.$emit('query:changed', newQueryString);
}, },
clearSearch() { clearSearch() {
const newQueryString = this.generateQueryData({
sorting: this.sorting,
});
this.$emit('filter:changed', []); this.$emit('filter:changed', []);
this.$emit('filter:submit'); this.$emit('filter:submit');
this.$emit('query:changed', newQueryString);
}, },
}, },
}; };
...@@ -69,7 +112,7 @@ export default { ...@@ -69,7 +112,7 @@ export default {
class="gl-mr-4 gl-flex-fill-1" class="gl-mr-4 gl-flex-fill-1"
:placeholder="__('Filter results')" :placeholder="__('Filter results')"
:available-tokens="tokens" :available-tokens="tokens"
@submit="$emit('filter:submit')" @submit="submitSearch"
@clear="clearSearch" @clear="clearSearch"
/> />
<gl-sorting <gl-sorting
......
...@@ -2,11 +2,18 @@ ...@@ -2,11 +2,18 @@
import { historyPushState } from '~/lib/utils/common_utils'; import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
/**
* Renderless component to update the query string,
* the update is done by updating the query property or
* by using updateQuery method in the scoped slot.
* note: do not use both prop and updateQuery method.
*/
export default { export default {
props: { props: {
query: { query: {
type: Object, type: Object,
required: true, required: false,
default: null,
}, },
}, },
watch: { watch: {
...@@ -14,12 +21,19 @@ export default { ...@@ -14,12 +21,19 @@ export default {
immediate: true, immediate: true,
deep: true, deep: true,
handler(newQuery) { handler(newQuery) {
historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true })); if (newQuery) {
this.updateQuery(newQuery);
}
},
}, },
}, },
methods: {
updateQuery(newQuery) {
historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }));
},
}, },
render() { render() {
return this.$slots.default; return this.$scopedSlots.default?.({ updateQuery: this.updateQuery });
}, },
}; };
</script> </script>
import {
getQueryParams,
keyValueToFilterToken,
searchArrayToFilterTokens,
} from '~/packages_and_registries/shared/utils';
describe('Packages And Registries shared utils', () => {
describe('getQueryParams', () => {
it('returns an object from a query string, with arrays', () => {
const queryString = 'foo=bar&baz[]=1&baz[]=2';
expect(getQueryParams(queryString)).toStrictEqual({ foo: 'bar', baz: ['1', '2'] });
});
});
describe('keyValueToFilterToken', () => {
it('returns an object in the correct form', () => {
const type = 'myType';
const data = 1;
expect(keyValueToFilterToken(type, data)).toStrictEqual({ type, value: { data } });
});
});
describe('searchArrayToFilterTokens', () => {
it('returns an array of objects in the correct form', () => {
const search = ['one', 'two'];
expect(searchArrayToFilterTokens(search)).toStrictEqual([
{ type: 'filtered-search-term', value: { data: 'one' } },
{ type: 'filtered-search-term', value: { data: 'two' } },
]);
});
});
});
...@@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo'; ...@@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import DeleteImage from '~/registry/explorer/components/delete_image.vue'; import DeleteImage from '~/registry/explorer/components/delete_image.vue';
import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue'; import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue';
import GroupEmptyState from '~/registry/explorer/components/list_page/group_empty_state.vue'; import GroupEmptyState from '~/registry/explorer/components/list_page/group_empty_state.vue';
...@@ -343,7 +344,7 @@ describe('List Page', () => { ...@@ -343,7 +344,7 @@ describe('List Page', () => {
const doSearch = async () => { const doSearch = async () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findRegistrySearch().vm.$emit('filter:changed', [ findRegistrySearch().vm.$emit('filter:changed', [
{ type: 'filtered-search-term', value: { data: 'centos6' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } },
]); ]);
findRegistrySearch().vm.$emit('filter:submit'); findRegistrySearch().vm.$emit('filter:submit');
......
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import component from '~/vue_shared/components/registry/registry_search.vue'; import component from '~/vue_shared/components/registry/registry_search.vue';
describe('Registry Search', () => { describe('Registry Search', () => {
...@@ -12,8 +13,18 @@ describe('Registry Search', () => { ...@@ -12,8 +13,18 @@ describe('Registry Search', () => {
const defaultProps = { const defaultProps = {
filter: [], filter: [],
sorting: { sort: 'asc', orderBy: 'name' }, sorting: { sort: 'asc', orderBy: 'name' },
tokens: ['foo'], tokens: [{ type: 'foo' }],
sortableFields: [{ label: 'name', orderBy: 'name' }, { label: 'baz' }], sortableFields: [
{ label: 'name', orderBy: 'name' },
{ label: 'baz', orderBy: 'bar' },
],
};
const defaultQueryChangedPayload = {
foo: '',
orderBy: 'name',
search: [],
sort: 'asc',
}; };
const mountComponent = (propsData = defaultProps) => { const mountComponent = (propsData = defaultProps) => {
...@@ -55,20 +66,22 @@ describe('Registry Search', () => { ...@@ -55,20 +66,22 @@ describe('Registry Search', () => {
expect(wrapper.emitted('filter:changed')).toEqual([['foo']]); expect(wrapper.emitted('filter:changed')).toEqual([['foo']]);
}); });
it('emits filter:submit on submit event', () => { it('emits filter:submit and query:changed on submit event', () => {
mountComponent(); mountComponent();
findFilteredSearch().vm.$emit('submit'); findFilteredSearch().vm.$emit('submit');
expect(wrapper.emitted('filter:submit')).toEqual([[]]); expect(wrapper.emitted('filter:submit')).toEqual([[]]);
expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]);
}); });
it('emits filter:changed and filter:submit on clear event', () => { it('emits filter:changed, filter:submit and query:changed on clear event', () => {
mountComponent(); mountComponent();
findFilteredSearch().vm.$emit('clear'); findFilteredSearch().vm.$emit('clear');
expect(wrapper.emitted('filter:changed')).toEqual([[[]]]); expect(wrapper.emitted('filter:changed')).toEqual([[[]]]);
expect(wrapper.emitted('filter:submit')).toEqual([[]]); expect(wrapper.emitted('filter:submit')).toEqual([[]]);
expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]);
}); });
it('binds tokens prop', () => { it('binds tokens prop', () => {
...@@ -90,15 +103,47 @@ describe('Registry Search', () => { ...@@ -90,15 +103,47 @@ describe('Registry Search', () => {
findPackageListSorting().vm.$emit('sortDirectionChange'); findPackageListSorting().vm.$emit('sortDirectionChange');
expect(wrapper.emitted('sorting:changed')).toEqual([[{ sort: 'desc' }]]); expect(wrapper.emitted('sorting:changed')).toEqual([[{ sort: 'desc' }]]);
expect(wrapper.emitted('query:changed')).toEqual([
[{ ...defaultQueryChangedPayload, sort: 'desc' }],
]);
}); });
it('on sort item click emits sorting:changed event ', () => { it('on sort item click emits sorting:changed event ', () => {
mountComponent(); mountComponent();
findSortingItems().at(0).vm.$emit('click'); findSortingItems().at(1).vm.$emit('click');
expect(wrapper.emitted('sorting:changed')).toEqual([ expect(wrapper.emitted('sorting:changed')).toEqual([
[{ orderBy: defaultProps.sortableFields[0].orderBy }], [{ orderBy: defaultProps.sortableFields[1].orderBy }],
]);
expect(wrapper.emitted('query:changed')).toEqual([
[{ ...defaultQueryChangedPayload, orderBy: 'bar' }],
]);
});
});
describe('query string calculation', () => {
const filter = [
{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } },
{ type: FILTERED_SEARCH_TERM, value: { data: 'two' } },
{ type: 'typeOne', value: { data: 'value_one' } },
{ type: 'typeTwo', value: { data: 'value_two' } },
];
it('aggregates the filter in the correct object', () => {
mountComponent({ ...defaultProps, filter });
findFilteredSearch().vm.$emit('submit');
expect(wrapper.emitted('query:changed')).toEqual([
[
{
...defaultQueryChangedPayload,
search: ['one', 'two'],
typeOne: 'value_one',
typeTwo: 'value_two',
},
],
]); ]);
}); });
}); });
......
...@@ -17,38 +17,81 @@ describe('url sync component', () => { ...@@ -17,38 +17,81 @@ describe('url sync component', () => {
const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] }; const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] };
const TEST_HOST = 'http://testhost/'; const TEST_HOST = 'http://testhost/';
jest.mock();
setWindowLocation(TEST_HOST); setWindowLocation(TEST_HOST);
const createComponent = () => { const findButton = () => wrapper.find('button');
const createComponent = ({ query = mockQuery, scopedSlots, slots } = {}) => {
wrapper = shallowMount(UrlSyncComponent, { wrapper = shallowMount(UrlSyncComponent, {
propsData: { query: mockQuery }, propsData: { query },
scopedSlots,
slots,
}); });
}; };
function expectUrlSync(query) { afterEach(() => {
expect(mergeUrlParams).toHaveBeenCalledTimes(1); wrapper.destroy();
});
const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => {
expect(mergeUrlParams).toHaveBeenCalledTimes(times);
expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true }); expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true });
const mergeUrlParamsReturnValue = mergeUrlParams.mock.results[0].value; expect(historyPushState).toHaveBeenCalledTimes(times);
expect(historyPushState).toHaveBeenCalledTimes(1);
expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue); expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue);
} };
beforeEach(() => { describe('with query as a props', () => {
it('immediately syncs the query to the URL', () => {
createComponent(); createComponent();
});
it('immediately syncs the query to the URL', () => expectUrlSync(mockQuery)); expectUrlSync(mockQuery, 1, mergeUrlParams.mock.results[0].value);
});
describe('when the query is modified', () => { describe('when the query is modified', () => {
const newQuery = { foo: true }; const newQuery = { foo: true };
beforeEach(() => {
mergeUrlParams.mockClear(); it('updates the URL with the new query', async () => {
historyPushState.mockClear(); createComponent();
wrapper.setProps({ query: newQuery }); // using setProps to test the watcher
await wrapper.setProps({ query: newQuery });
expectUrlSync(mockQuery, 2, mergeUrlParams.mock.results[1].value);
});
}); });
});
describe('with scoped slot', () => {
const scopedSlots = {
default: `
<button @click="props.updateQuery({bar: 'baz'})">Update Query </button>
`,
};
it('updates the URL with the new query', () => expectUrlSync(newQuery)); it('renders the scoped slot', () => {
createComponent({ query: null, scopedSlots });
expect(findButton().exists()).toBe(true);
});
it('syncs the url with the scoped slots function', () => {
createComponent({ query: null, scopedSlots });
findButton().trigger('click');
expectUrlSync({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value);
});
});
describe('with slot', () => {
const slots = {
default: '<button>Normal Slot</button>',
};
it('renders the default slot', () => {
createComponent({ query: null, slots });
expect(findButton().exists()).toBe(true);
});
}); });
}); });
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