Commit b40ca001 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'ss/add-url-params-epic-boards' into 'master'

Add filtering of data to boards with param support

See merge request gitlab-org/gitlab!59197
parents e71b2c58 9a11d79e
<script>
import { mapActions, mapState } from 'vuex';
import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
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';
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';
export default {
i18n: {
search: __('Search'),
label: __('Label'),
author: __('Author'),
},
components: { FilteredSearch },
inject: ['search'],
inject: ['initialFilterParams'],
data() {
return {
filterParams: this.initialFilterParams,
};
},
computed: {
...mapState(['fullPath']),
initialSearch() {
return [{ type: 'filtered-search-term', value: { data: this.search } }];
},
tokens() {
return [
{
icon: 'labels',
title: __('Label'),
type: 'labels',
title: this.$options.i18n.label,
type: 'label_name',
operators: [{ value: '=', description: 'is' }],
token: LabelToken,
unique: false,
......@@ -34,8 +37,8 @@ export default {
},
{
icon: 'pencil',
title: __('Author'),
type: 'author',
title: this.$options.i18n.author,
type: 'author_username',
operators: [{ value: '=', description: 'is' }],
symbol: '@',
token: AuthorToken,
......@@ -44,9 +47,74 @@ export default {
},
];
},
urlParams() {
const { authorUsername, labelName, search } = this.filterParams;
return {
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 },
});
}
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) {
return this.$apollo
.query({
......@@ -69,11 +137,13 @@ export default {
})
.then(({ data }) => data.group?.labels.nodes || []);
},
handleSearch(filters = []) {
const [item] = filters;
const search = item?.value?.data || '';
historyPushState(setUrlParams({ search }));
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();
},
......@@ -88,7 +158,7 @@ export default {
namespace=""
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="initialSearch"
@onFilter="handleSearch"
:initial-filter-value="getFilteredSearchValue()"
@onFilter="handleFilterEpics"
/>
</template>
import Vue from 'vue';
import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue';
import store from '~/boards/stores';
import { queryToObject } from '~/lib/utils/url_utility';
import { urlParamsToObject, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default (apolloProvider) => {
const queryParams = queryToObject(window.location.search);
const el = document.getElementById('js-board-filtered-search');
const rawFilterParams = urlParamsToObject(window.location.search);
const initialFilterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {}),
};
if (!el) {
return null;
......@@ -14,7 +17,7 @@ export default (apolloProvider) => {
return new Vue({
el,
provide: {
search: queryParams?.search || '',
initialFilterParams,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider,
......
query EpicUsers($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) {
groupMembers(relations: [DIRECT, DESCENDANTS], search: $search) {
groupMembers(relations: [DIRECT, DESCENDANTS, INHERITED], search: $search) {
nodes {
user {
id
......
......@@ -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(: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(:epic3) { create(:epic, group: group, labels: [label2], title: 'Epic3') }
......@@ -166,22 +166,39 @@ RSpec.describe 'epic boards', :js do
group.add_guest(user)
sign_in(user)
visit_epic_boards_page
# Focus on search field
find_field('Search').click
end
it 'can select an Author and Label' do
page.find('[data-testid="epic-filtered-search"]').click
it 'can select a Label in order to filter the board' do
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
click_link 'Author'
wait_for_requests
click_link user.name
click_link 'Label'
wait_for_requests
click_link label.title
expect(page).to have_text("Author = #{user.name} Label = ~#{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
end
......
......@@ -2,7 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue';
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 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';
......@@ -15,10 +15,10 @@ describe('EpicFilteredSearch', () => {
let wrapper;
let store;
const createComponent = () => {
const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(EpicFilteredSearch, {
localVue,
provide: { search: '' },
provide: { initialFilterParams },
store,
});
};
......@@ -52,7 +52,7 @@ describe('EpicFilteredSearch', () => {
{
icon: 'labels',
title: __('Label'),
type: 'labels',
type: 'label_name',
operators: [{ value: '=', description: 'is' }],
token: LabelToken,
unique: false,
......@@ -62,7 +62,7 @@ describe('EpicFilteredSearch', () => {
{
icon: 'pencil',
title: __('Author'),
type: 'author',
type: 'author_username',
operators: [{ value: '=', description: 'is' }],
symbol: '@',
token: AuthorToken,
......@@ -82,13 +82,58 @@ describe('EpicFilteredSearch', () => {
});
it('calls historyPushState', () => {
jest.spyOn(commonUtils, 'historyPushState');
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
expect(commonUtils.historyPushState).toHaveBeenCalledWith(
'http://test.host/?search=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' } },
{ 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