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';
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 { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageSearch from './package_search.vue';
import PackageTitle from './package_title.vue';
......@@ -30,7 +31,7 @@ export default {
}),
emptySearch() {
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 {
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 Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue';
......@@ -241,7 +242,7 @@ export default {
};
},
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;
},
},
......
<script>
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
const ASCENDING_ORDER = 'asc';
const DESCENDING_ORDER = 'desc';
......@@ -45,18 +46,60 @@ export default {
isSortAscending() {
return this.sorting.sort === ASCENDING_ORDER;
},
baselineQueryStringFilters() {
return this.tokens.reduce((acc, curr) => {
acc[curr.type] = '';
return acc;
}, {});
},
},
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() {
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('query:changed', newQueryString);
},
onSortItemClick(item) {
const newQueryString = this.generateQueryData({
sorting: { ...this.sorting, orderBy: item },
filter: this.filter,
});
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() {
const newQueryString = this.generateQueryData({
sorting: this.sorting,
});
this.$emit('filter:changed', []);
this.$emit('filter:submit');
this.$emit('query:changed', newQueryString);
},
},
};
......@@ -69,7 +112,7 @@ export default {
class="gl-mr-4 gl-flex-fill-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
@submit="$emit('filter:submit')"
@submit="submitSearch"
@clear="clearSearch"
/>
<gl-sorting
......
......@@ -2,11 +2,18 @@
import { historyPushState } from '~/lib/utils/common_utils';
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 {
props: {
query: {
type: Object,
required: true,
required: false,
default: null,
},
},
watch: {
......@@ -14,12 +21,19 @@ export default {
immediate: true,
deep: true,
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() {
return this.$slots.default;
return this.$scopedSlots.default?.({ updateQuery: this.updateQuery });
},
};
</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';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
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 CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue';
import GroupEmptyState from '~/registry/explorer/components/list_page/group_empty_state.vue';
......@@ -343,7 +344,7 @@ describe('List Page', () => {
const doSearch = async () => {
await waitForApolloRequestRender();
findRegistrySearch().vm.$emit('filter:changed', [
{ type: 'filtered-search-term', value: { data: 'centos6' } },
{ type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } },
]);
findRegistrySearch().vm.$emit('filter:submit');
......
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
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';
describe('Registry Search', () => {
......@@ -12,8 +13,18 @@ describe('Registry Search', () => {
const defaultProps = {
filter: [],
sorting: { sort: 'asc', orderBy: 'name' },
tokens: ['foo'],
sortableFields: [{ label: 'name', orderBy: 'name' }, { label: 'baz' }],
tokens: [{ type: 'foo' }],
sortableFields: [
{ label: 'name', orderBy: 'name' },
{ label: 'baz', orderBy: 'bar' },
],
};
const defaultQueryChangedPayload = {
foo: '',
orderBy: 'name',
search: [],
sort: 'asc',
};
const mountComponent = (propsData = defaultProps) => {
......@@ -55,20 +66,22 @@ describe('Registry Search', () => {
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();
findFilteredSearch().vm.$emit('submit');
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();
findFilteredSearch().vm.$emit('clear');
expect(wrapper.emitted('filter:changed')).toEqual([[[]]]);
expect(wrapper.emitted('filter:submit')).toEqual([[]]);
expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]);
});
it('binds tokens prop', () => {
......@@ -90,15 +103,47 @@ describe('Registry Search', () => {
findPackageListSorting().vm.$emit('sortDirectionChange');
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 ', () => {
mountComponent();
findSortingItems().at(0).vm.$emit('click');
findSortingItems().at(1).vm.$emit('click');
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', () => {
const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] };
const TEST_HOST = 'http://testhost/';
jest.mock();
setWindowLocation(TEST_HOST);
const createComponent = () => {
const findButton = () => wrapper.find('button');
const createComponent = ({ query = mockQuery, scopedSlots, slots } = {}) => {
wrapper = shallowMount(UrlSyncComponent, {
propsData: { query: mockQuery },
propsData: { query },
scopedSlots,
slots,
});
};
function expectUrlSync(query) {
expect(mergeUrlParams).toHaveBeenCalledTimes(1);
afterEach(() => {
wrapper.destroy();
});
const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => {
expect(mergeUrlParams).toHaveBeenCalledTimes(times);
expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true });
const mergeUrlParamsReturnValue = mergeUrlParams.mock.results[0].value;
expect(historyPushState).toHaveBeenCalledTimes(1);
expect(historyPushState).toHaveBeenCalledTimes(times);
expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue);
}
};
describe('with query as a props', () => {
it('immediately syncs the query to the URL', () => {
createComponent();
beforeEach(() => {
createComponent();
expectUrlSync(mockQuery, 1, mergeUrlParams.mock.results[0].value);
});
describe('when the query is modified', () => {
const newQuery = { foo: true };
it('updates the URL with the new query', async () => {
createComponent();
// using setProps to test the watcher
await wrapper.setProps({ query: newQuery });
expectUrlSync(mockQuery, 2, mergeUrlParams.mock.results[1].value);
});
});
});
it('immediately syncs the query to the URL', () => expectUrlSync(mockQuery));
describe('with scoped slot', () => {
const scopedSlots = {
default: `
<button @click="props.updateQuery({bar: 'baz'})">Update Query </button>
`,
};
describe('when the query is modified', () => {
const newQuery = { foo: true };
beforeEach(() => {
mergeUrlParams.mockClear();
historyPushState.mockClear();
wrapper.setProps({ query: newQuery });
it('renders the scoped slot', () => {
createComponent({ query: null, scopedSlots });
expect(findButton().exists()).toBe(true);
});
it('updates the URL with the new query', () => expectUrlSync(newQuery));
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