Commit a7691fb4 authored by sstern's avatar sstern Committed by Jose Ivan Vargas

Abstract logic for boards filtered search

parent 8594ed66
<script>
import { pickBy } from 'lodash';
import { mapActions } from 'vuex';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
export default {
i18n: {
search: __('Search'),
label: __('Label'),
author: __('Author'),
},
components: { FilteredSearch },
inject: ['initialFilterParams'],
props: {
tokens: {
type: Array,
required: true,
},
},
data() {
return {
filterParams: this.initialFilterParams,
};
},
computed: {
urlParams() {
const { authorUsername, labelName, search } = this.filterParams;
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
notParams = pickBy(
{
'not[label_name][]': this.filterParams.not.labelName,
'not[author_username]': this.filterParams.not.authorUsername,
},
undefined,
);
}
return {
...notParams,
author_username: authorUsername,
'label_name[]': labelName,
search,
};
},
},
methods: {
...mapActions(['performSearch']),
handleFilter(filters) {
this.filterParams = this.getFilterParams(filters);
updateHistory({
url: setUrlParams(this.urlParams, window.location.href, true, false, true),
title: document.title,
replace: true,
});
this.performSearch();
},
getFilteredSearchValue() {
const { authorUsername, labelName, search } = this.filterParams;
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername, operator: '=' },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label, operator: '=' },
})),
);
}
if (this.filterParams['not[authorUsername]']) {
filteredSearchValue.push({
type: 'author_username',
value: { data: this.filterParams['not[authorUsername]'], operator: '!=' },
});
}
if (this.filterParams['not[labelName]']) {
filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({
type: 'label_name',
value: { data: label, operator: '!=' },
})),
);
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
getFilterParams(filters = []) {
const notFilters = filters.filter((item) => item.value.operator === '!=');
const equalsFilters = filters.filter((item) => item.value.operator === '=');
return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } };
},
generateParams(filters = []) {
const filterParams = {};
const labels = [];
const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
default:
break;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
return filterParams;
},
},
};
</script>
<template>
<filtered-search
class="gl-w-full"
namespace=""
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="getFilteredSearchValue()"
@onFilter="handleFilter"
/>
</template>
......@@ -22,7 +22,7 @@
.check-all-holder.d-none.d-sm-block.hidden
= check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
- if Feature.enabled?(:boards_filtered_search, @group) && is_epic_board
#js-board-filtered-search
#js-board-filtered-search{ data: { full_path: @group&.full_path } }
- else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
......
<script>
import { pickBy } from 'lodash';
import { mapActions, mapState } from 'vuex';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import groupLabelsQuery from '../graphql/group_labels.query.graphql';
......@@ -17,15 +14,9 @@ export default {
is: __('is'),
isNot: __('is not'),
},
components: { FilteredSearch },
inject: ['initialFilterParams'],
data() {
return {
filterParams: this.initialFilterParams,
};
},
components: { BoardFilteredSearch },
inject: ['fullPath'],
computed: {
...mapState(['fullPath']),
tokens() {
const { label, is, isNot, author } = this.$options.i18n;
return [
......@@ -57,108 +48,8 @@ export default {
},
];
},
urlParams() {
const { authorUsername, labelName, search } = this.filterParams;
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
notParams = pickBy(
{
'not[label_name][]': this.filterParams.not.labelName,
'not[author_username]': this.filterParams.not.authorUsername,
},
undefined,
);
}
return {
...notParams,
author_username: authorUsername,
'label_name[]': labelName,
search,
};
},
},
methods: {
...mapActions(['performSearch']),
getFilteredSearchValue() {
const { authorUsername, labelName, search } = this.filterParams;
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername, operator: '=' },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label, operator: '=' },
})),
);
}
if (this.filterParams['not[authorUsername]']) {
filteredSearchValue.push({
type: 'author_username',
value: { data: this.filterParams['not[authorUsername]'], operator: '!=' },
});
}
if (this.filterParams['not[labelName]']) {
filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({
type: 'label_name',
value: { data: label, operator: '!=' },
})),
);
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
getFilterParams(filters = []) {
const notFilters = filters.filter((item) => item.value.operator === '!=');
const equalsFilters = filters.filter((item) => item.value.operator === '=');
return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } };
},
generateParams(filters = []) {
const filterParams = {};
const labels = [];
const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
default:
break;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
return filterParams;
},
fetchAuthors(authorsSearchTerm) {
return this.$apollo
.query({
......@@ -181,28 +72,10 @@ export default {
})
.then(({ data }) => data.group?.labels.nodes || []);
},
handleFilterEpics(filters) {
this.filterParams = this.getFilterParams(filters);
updateHistory({
url: setUrlParams(this.urlParams, window.location.href, true, false, true),
title: document.title,
replace: true,
});
this.performSearch();
},
},
};
</script>
<template>
<filtered-search
data-testid="epic-filtered-search"
class="gl-w-full"
namespace=""
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="getFilteredSearchValue()"
@onFilter="handleFilterEpics"
/>
<board-filtered-search data-testid="epic-filtered-search" :tokens="tokens" />
</template>
......@@ -9,7 +9,7 @@ export default (apolloProvider) => {
const initialFilterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {}),
};
const { fullPath } = el.dataset;
if (!el) {
return null;
}
......@@ -18,6 +18,7 @@ export default (apolloProvider) => {
el,
provide: {
initialFilterParams,
fullPath,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider,
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue';
import { createStore } from '~/boards/stores';
import * as urlUtility from '~/lib/utils/url_utility';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import { __ } from '~/locale';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EpicFilteredSearch', () => {
let wrapper;
let store;
const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(EpicFilteredSearch, {
localVue,
provide: { initialFilterParams },
provide: { initialFilterParams, fullPath: '' },
store,
});
};
const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot);
beforeEach(() => {
// this needed for actions call for performSearch
window.gon = { features: {} };
});
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent();
});
it('renders FilteredSearch', () => {
expect(findFilteredSearch().exists()).toBe(true);
it('finds BoardFilteredSearch', () => {
expect(wrapper.find(BoardFilteredSearch).exists()).toBe(true);
});
it('passes the correct tokens to FilteredSearch', () => {
it('passes tokens to BoardFilteredSearch', () => {
const tokens = [
{
icon: 'labels',
......@@ -76,70 +58,7 @@ describe('EpicFilteredSearch', () => {
fetchAuthors: wrapper.vm.fetchAuthors,
},
];
expect(findFilteredSearch().props('tokens')).toEqual(tokens);
});
describe('when onFilter is emitted', () => {
it('calls performSearch', () => {
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
expect(store.dispatch).toHaveBeenCalledWith('performSearch');
});
it('calls historyPushState', () => {
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
expect(urlUtility.updateHistory).toHaveBeenCalledWith({
replace: true,
title: '',
url: 'http://test.host/',
});
});
});
});
describe('when searching', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent();
});
it('sets the url params to the correct results', async () => {
const mockFilters = [
{ type: 'author_username', value: { data: 'root', operator: '=' } },
{ type: 'label_name', value: { data: 'label', operator: '=' } },
{ type: 'label_name', value: { data: 'label2', operator: '!=' } },
];
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', mockFilters);
expect(urlUtility.updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
url: 'http://test.host/?not[label_name][]=label2&author_username=root&label_name[]=label',
});
});
});
describe('when url params are already set', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } });
});
it('passes the correct props to FitlerSearchBar', async () => {
expect(findFilteredSearch().props('initialFilterValue')).toEqual([
{ type: 'author_username', value: { data: 'root', operator: '=' } },
{ type: 'label_name', value: { data: 'label', operator: '=' } },
]);
expect(wrapper.find(BoardFilteredSearch).props('tokens')).toEqual(tokens);
});
});
});
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import { createStore } from '~/boards/stores';
import * as urlUtility from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
Vue.use(Vuex);
describe('BoardFilteredSearch', () => {
let wrapper;
let store;
const tokens = [
{
icon: 'labels',
title: __('Label'),
type: 'label_name',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
token: LabelToken,
unique: false,
symbol: '~',
fetchLabels: () => new Promise(() => {}),
},
{
icon: 'pencil',
title: __('Author'),
type: 'author_username',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors: () => new Promise(() => {}),
},
];
const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(BoardFilteredSearch, {
provide: { initialFilterParams, fullPath: '' },
store,
propsData: {
tokens,
},
});
};
const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot);
beforeEach(() => {
// this needed for actions call for performSearch
window.gon = { features: {} };
});
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent();
});
it('renders FilteredSearch', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
it('passes the correct tokens to FilteredSearch', () => {
expect(findFilteredSearch().props('tokens')).toEqual(tokens);
});
describe('when onFilter is emitted', () => {
it('calls performSearch', () => {
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
expect(store.dispatch).toHaveBeenCalledWith('performSearch');
});
it('calls historyPushState', () => {
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
expect(urlUtility.updateHistory).toHaveBeenCalledWith({
replace: true,
title: '',
url: 'http://test.host/',
});
});
});
});
describe('when searching', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent();
});
it('sets the url params to the correct results', async () => {
const mockFilters = [
{ type: 'author_username', value: { data: 'root', operator: '=' } },
{ type: 'label_name', value: { data: 'label', operator: '=' } },
{ type: 'label_name', value: { data: 'label2', operator: '=' } },
];
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', mockFilters);
expect(urlUtility.updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
url: 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2',
});
});
});
describe('when url params are already set', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } });
});
it('passes the correct props to FilterSearchBar', () => {
expect(findFilteredSearch().props('initialFilterValue')).toEqual([
{ type: 'author_username', value: { data: 'root', operator: '=' } },
{ type: 'label_name', value: { data: 'label', operator: '=' } },
]);
});
});
});
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