Commit 9a11d79e authored by Scott Stern's avatar Scott Stern Committed by Natalia Tepluhina

Add filtering of data to boards with param support

parent 20a537c8
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { historyPushState } from '~/lib/utils/common_utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; 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 AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
...@@ -12,20 +11,24 @@ import groupUsersQuery from '../graphql/group_members.query.graphql'; ...@@ -12,20 +11,24 @@ import groupUsersQuery from '../graphql/group_members.query.graphql';
export default { export default {
i18n: { i18n: {
search: __('Search'), search: __('Search'),
label: __('Label'),
author: __('Author'),
}, },
components: { FilteredSearch }, components: { FilteredSearch },
inject: ['search'], inject: ['initialFilterParams'],
data() {
return {
filterParams: this.initialFilterParams,
};
},
computed: { computed: {
...mapState(['fullPath']), ...mapState(['fullPath']),
initialSearch() {
return [{ type: 'filtered-search-term', value: { data: this.search } }];
},
tokens() { tokens() {
return [ return [
{ {
icon: 'labels', icon: 'labels',
title: __('Label'), title: this.$options.i18n.label,
type: 'labels', type: 'label_name',
operators: [{ value: '=', description: 'is' }], operators: [{ value: '=', description: 'is' }],
token: LabelToken, token: LabelToken,
unique: false, unique: false,
...@@ -34,8 +37,8 @@ export default { ...@@ -34,8 +37,8 @@ export default {
}, },
{ {
icon: 'pencil', icon: 'pencil',
title: __('Author'), title: this.$options.i18n.author,
type: 'author', type: 'author_username',
operators: [{ value: '=', description: 'is' }], operators: [{ value: '=', description: 'is' }],
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
...@@ -44,9 +47,74 @@ export default { ...@@ -44,9 +47,74 @@ export default {
}, },
]; ];
}, },
urlParams() {
const { authorUsername, labelName, search } = this.filterParams;
return {
author_username: authorUsername,
'label_name[]': labelName,
search,
};
},
}, },
methods: { methods: {
...mapActions(['performSearch']), ...mapActions(['performSearch']),
getFilteredSearchValue() {
const { authorUsername, labelName, search } = this.filterParams;
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label },
})),
);
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
getFilterParams(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) { fetchAuthors(authorsSearchTerm) {
return this.$apollo return this.$apollo
.query({ .query({
...@@ -69,11 +137,13 @@ export default { ...@@ -69,11 +137,13 @@ export default {
}) })
.then(({ data }) => data.group?.labels.nodes || []); .then(({ data }) => data.group?.labels.nodes || []);
}, },
handleSearch(filters = []) { handleFilterEpics(filters) {
const [item] = filters; this.filterParams = this.getFilterParams(filters);
const search = item?.value?.data || ''; updateHistory({
url: setUrlParams(this.urlParams, window.location.href, true, false, true),
historyPushState(setUrlParams({ search })); title: document.title,
replace: true,
});
this.performSearch(); this.performSearch();
}, },
...@@ -88,7 +158,7 @@ export default { ...@@ -88,7 +158,7 @@ export default {
namespace="" namespace=""
:tokens="tokens" :tokens="tokens"
:search-input-placeholder="$options.i18n.search" :search-input-placeholder="$options.i18n.search"
:initial-filter-value="initialSearch" :initial-filter-value="getFilteredSearchValue()"
@onFilter="handleSearch" @onFilter="handleFilterEpics"
/> />
</template> </template>
import Vue from 'vue'; import Vue from 'vue';
import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue'; import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue';
import store from '~/boards/stores'; import store from '~/boards/stores';
import { queryToObject } from '~/lib/utils/url_utility'; import { urlParamsToObject, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default (apolloProvider) => { export default (apolloProvider) => {
const queryParams = queryToObject(window.location.search);
const el = document.getElementById('js-board-filtered-search'); const el = document.getElementById('js-board-filtered-search');
const rawFilterParams = urlParamsToObject(window.location.search);
const initialFilterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {}),
};
if (!el) { if (!el) {
return null; return null;
...@@ -14,7 +17,7 @@ export default (apolloProvider) => { ...@@ -14,7 +17,7 @@ export default (apolloProvider) => {
return new Vue({ return new Vue({
el, el,
provide: { provide: {
search: queryParams?.search || '', initialFilterParams,
}, },
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider, apolloProvider,
......
query EpicUsers($fullPath: ID!, $search: String) { query EpicUsers($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
groupMembers(relations: [DIRECT, DESCENDANTS], search: $search) { groupMembers(relations: [DIRECT, DESCENDANTS, INHERITED], search: $search) {
nodes { nodes {
user { user {
id id
......
...@@ -16,7 +16,7 @@ RSpec.describe 'epic boards', :js do ...@@ -16,7 +16,7 @@ RSpec.describe 'epic boards', :js do
let_it_be(:backlog_list) { create(:epic_list, epic_board: epic_board, list_type: :backlog) } let_it_be(:backlog_list) { create(:epic_list, epic_board: epic_board, list_type: :backlog) }
let_it_be(:closed_list) { create(:epic_list, epic_board: epic_board, list_type: :closed) } let_it_be(:closed_list) { create(:epic_list, epic_board: epic_board, list_type: :closed) }
let_it_be(:epic1) { create(:epic, group: group, labels: [label], title: 'Epic1') } let_it_be(:epic1) { create(:epic, group: group, labels: [label], author: user, title: 'Epic1') }
let_it_be(:epic2) { create(:epic, group: group, title: 'Epic2') } let_it_be(:epic2) { create(:epic, group: group, title: 'Epic2') }
let_it_be(:epic3) { create(:epic, group: group, labels: [label2], title: 'Epic3') } let_it_be(:epic3) { create(:epic, group: group, labels: [label2], title: 'Epic3') }
...@@ -166,22 +166,39 @@ RSpec.describe 'epic boards', :js do ...@@ -166,22 +166,39 @@ RSpec.describe 'epic boards', :js do
group.add_guest(user) group.add_guest(user)
sign_in(user) sign_in(user)
visit_epic_boards_page visit_epic_boards_page
# Focus on search field
find_field('Search').click
end end
it 'can select an Author and Label' do it 'can select a Label in order to filter the board' do
page.find('[data-testid="epic-filtered-search"]').click page.within('[data-testid="epic-filtered-search"]') do
click_link 'Label'
click_link label.title
find('input').native.send_keys(:return)
end
wait_for_requests
expect(page).to have_content('Epic1')
expect(page).not_to have_content('Epic2')
expect(page).not_to have_content('Epic3')
end
it 'can select an Author in order to filter the board' do
page.within('[data-testid="epic-filtered-search"]') do page.within('[data-testid="epic-filtered-search"]') do
click_link 'Author' click_link 'Author'
wait_for_requests
click_link user.name click_link user.name
click_link 'Label' find('input').native.send_keys(:return)
end
wait_for_requests wait_for_requests
click_link label.title
expect(page).to have_text("Author = #{user.name} Label = ~#{label.title}") expect(page).to have_content('Epic1')
end expect(page).not_to have_content('Epic2')
expect(page).not_to have_content('Epic3')
end end
end end
......
...@@ -2,7 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue'; import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue';
import { createStore } from '~/boards/stores'; import { createStore } from '~/boards/stores';
import * as commonUtils from '~/lib/utils/common_utils'; import * as urlUtility from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; 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 AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
...@@ -15,10 +15,10 @@ describe('EpicFilteredSearch', () => { ...@@ -15,10 +15,10 @@ describe('EpicFilteredSearch', () => {
let wrapper; let wrapper;
let store; let store;
const createComponent = () => { const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(EpicFilteredSearch, { wrapper = shallowMount(EpicFilteredSearch, {
localVue, localVue,
provide: { search: '' }, provide: { initialFilterParams },
store, store,
}); });
}; };
...@@ -52,7 +52,7 @@ describe('EpicFilteredSearch', () => { ...@@ -52,7 +52,7 @@ describe('EpicFilteredSearch', () => {
{ {
icon: 'labels', icon: 'labels',
title: __('Label'), title: __('Label'),
type: 'labels', type: 'label_name',
operators: [{ value: '=', description: 'is' }], operators: [{ value: '=', description: 'is' }],
token: LabelToken, token: LabelToken,
unique: false, unique: false,
...@@ -62,7 +62,7 @@ describe('EpicFilteredSearch', () => { ...@@ -62,7 +62,7 @@ describe('EpicFilteredSearch', () => {
{ {
icon: 'pencil', icon: 'pencil',
title: __('Author'), title: __('Author'),
type: 'author', type: 'author_username',
operators: [{ value: '=', description: 'is' }], operators: [{ value: '=', description: 'is' }],
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
...@@ -82,13 +82,58 @@ describe('EpicFilteredSearch', () => { ...@@ -82,13 +82,58 @@ describe('EpicFilteredSearch', () => {
}); });
it('calls historyPushState', () => { it('calls historyPushState', () => {
jest.spyOn(commonUtils, 'historyPushState'); jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]); findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
expect(commonUtils.historyPushState).toHaveBeenCalledWith( expect(urlUtility.updateHistory).toHaveBeenCalledWith({
'http://test.host/?search=searchQuery', 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' } },
{ type: 'label_name', value: { data: 'label' } },
{ type: 'label_name', value: { data: 'label2' } },
];
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 FitlerSearchBar', async () => {
expect(findFilteredSearch().props('initialFilterValue')).toEqual([
{ type: 'author_username', value: { data: 'root' } },
{ type: 'label_name', value: { data: 'label' } },
]);
});
});
}); });
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