Commit 11c906bb authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'ss/add-labels-to-filtered-search' into 'master'

Add glfilteredsearch to group boards

See merge request gitlab-org/gitlab!56787
parents 07dbcb6d 71b9d730
<script>
import { mapActions } from 'vuex';
import { historyPushState } from '~/lib/utils/common_utils';
import { 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'),
},
components: { FilteredSearch },
props: {
search: {
type: String,
required: false,
default: '',
},
},
computed: {
initialSearch() {
return [{ type: 'filtered-search-term', value: { data: this.search } }];
},
},
methods: {
...mapActions(['performSearch']),
handleSearch(filters) {
let itemValue = '';
const [item] = filters;
if (filters.length === 0) {
itemValue = '';
} else {
itemValue = item?.value?.data;
}
historyPushState(setUrlParams({ search: itemValue }, window.location.href));
this.performSearch();
},
},
};
</script>
<template>
<filtered-search
class="gl-w-full"
namespace=""
:tokens="[]"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="initialSearch"
@onFilter="handleSearch"
/>
</template>
import Vue from 'vue';
import store from '~/boards/stores';
import { queryToObject } from '~/lib/utils/url_utility';
import FilteredSearch from './components/filtered_search.vue';
export default () => {
const queryParams = queryToObject(window.location.search);
const el = document.getElementById('js-board-filtered-search');
/*
When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed
we can remove apolloProvider option from here. Currently without it its causing
an error
*/
return new Vue({
el,
store,
apolloProvider: {},
render: (createElement) =>
createElement(FilteredSearch, {
props: { search: queryParams.search },
}),
});
};
......@@ -45,6 +45,9 @@ export default {
activeAuthor() {
return this.authors.find((author) => author.username.toLowerCase() === this.currentValue);
},
activeAuthorAvatar() {
return this.avatarUrl(this.activeAuthor);
},
},
watch: {
active: {
......@@ -74,6 +77,9 @@ export default {
this.loading = false;
});
},
avatarUrl(author) {
return author.avatarUrl || author.avatar_url;
},
searchAuthors: debounce(function debouncedSearch({ data }) {
this.fetchAuthorBySearchTerm(data);
}, DEBOUNCE_DELAY),
......@@ -92,7 +98,7 @@ export default {
<gl-avatar
v-if="activeAuthor"
:size="16"
:src="activeAuthor.avatar_url"
:src="activeAuthorAvatar"
shape="circle"
class="gl-mr-2"
/>
......@@ -115,7 +121,7 @@ export default {
:value="author.username"
>
<div class="d-flex">
<gl-avatar :size="32" :src="author.avatar_url" />
<gl-avatar :size="32" :src="avatarUrl(author)" />
<div>
<div>{{ author.name }}</div>
<div>@{{ author.username }}</div>
......
<script>
import { mapActions } from 'vuex';
import { mapActions, mapState } from 'vuex';
import { historyPushState } from '~/lib/utils/common_utils';
import { 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';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import groupLabelsQuery from '../graphql/group_labels.query.graphql';
import groupUsersQuery from '../graphql/group_members.query.graphql';
export default {
i18n: {
......@@ -12,14 +16,58 @@ export default {
components: { FilteredSearch },
inject: ['search'],
computed: {
...mapState(['fullPath']),
initialSearch() {
return [{ type: 'filtered-search-term', value: { data: this.search } }];
},
tokens() {
return [
{
icon: 'labels',
title: __('Label'),
type: 'labels',
operators: [{ value: '=', description: 'is' }],
token: LabelToken,
unique: false,
symbol: '~',
fetchLabels: this.fetchLabels,
},
{
icon: 'pencil',
title: __('Author'),
type: 'author',
operators: [{ value: '=', description: 'is' }],
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors: this.fetchAuthors,
},
];
},
},
methods: {
...mapActions(['performSearch']),
tokens() {
return [];
fetchAuthors(authorsSearchTerm) {
return this.$apollo
.query({
query: groupUsersQuery,
variables: {
fullPath: this.fullPath,
search: authorsSearchTerm,
},
})
.then(({ data }) => data.group?.groupMembers.nodes.map((item) => item.user));
},
fetchLabels(labelSearchTerm) {
return this.$apollo
.query({
query: groupLabelsQuery,
variables: {
fullPath: this.fullPath,
search: labelSearchTerm,
},
})
.then(({ data }) => data.group?.labels.nodes || []);
},
handleSearch(filters = []) {
const [item] = filters;
......@@ -35,9 +83,10 @@ export default {
<template>
<filtered-search
data-testid="epic-filtered-search"
class="gl-w-full"
namespace=""
:tokens="tokens()"
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="initialSearch"
@onFilter="handleSearch"
......
......@@ -3,7 +3,7 @@ import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_sea
import store from '~/boards/stores';
import { queryToObject } from '~/lib/utils/url_utility';
export default () => {
export default (apolloProvider) => {
const queryParams = queryToObject(window.location.search);
const el = document.getElementById('js-board-filtered-search');
......@@ -17,7 +17,7 @@ export default () => {
search: queryParams?.search || '',
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider: {},
apolloProvider,
render: (createElement) => createElement(EpicFilteredSearch),
});
};
query EpicLabels($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) {
labels(
includeAncestorGroups: true
includeDescendantGroups: true
first: 20
searchTerm: $search
) {
nodes {
id
color
title
textColor
}
}
}
}
query EpicUsers($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) {
groupMembers(relations: [DIRECT, DESCENDANTS], search: $search) {
nodes {
user {
id
name
username
avatarUrl
}
}
}
}
}
......@@ -44,7 +44,7 @@ export default () => {
if (gon?.features?.boardsFilteredSearch) {
import('ee/boards/epic_filtered_search')
.then(({ default: initFilteredSearch }) => {
initFilteredSearch();
initFilteredSearch(apolloProvider);
})
.catch(() => {});
}
......
......@@ -158,6 +158,33 @@ RSpec.describe 'epic boards', :js do
end
end
context 'filtered search' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(boards_filtered_search: true)
group.add_guest(user)
sign_in(user)
visit_epic_boards_page
end
it 'can select an Author and Label' do
page.find('[data-testid="epic-filtered-search"]').click
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}")
end
end
end
def visit_epic_boards_page
visit group_epic_boards_path(group)
wait_for_requests
......
......@@ -3,7 +3,10 @@ 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 { __ } 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);
......@@ -44,6 +47,33 @@ describe('EpicFilteredSearch', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
it('passes the correct tokens to FilteredSearch', () => {
const tokens = [
{
icon: 'labels',
title: __('Label'),
type: 'labels',
operators: [{ value: '=', description: 'is' }],
token: LabelToken,
unique: false,
symbol: '~',
fetchLabels: wrapper.vm.fetchLabels,
},
{
icon: 'pencil',
title: __('Author'),
type: 'author',
operators: [{ value: '=', description: 'is' }],
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors: wrapper.vm.fetchAuthors,
},
];
expect(findFilteredSearch().props('tokens')).toEqual(tokens);
});
describe('when onFilter is emitted', () => {
it('calls performSearch', () => {
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import FilteredSearch from '~/boards/components/filtered_search.vue';
import { createStore } from '~/boards/stores';
import * as commonUtils from '~/lib/utils/common_utils';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FilteredSearch', () => {
let wrapper;
let store;
const createComponent = () => {
wrapper = shallowMount(FilteredSearch, {
localVue,
propsData: { search: '' },
store,
attachTo: document.body,
});
};
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('finds FilteredSearch', () => {
expect(wrapper.find(FilteredSearchBarRoot).exists()).toBe(true);
});
describe('when onFilter is emitted', () => {
it('calls performSearch', () => {
wrapper.find(FilteredSearchBarRoot).vm.$emit('onFilter', [{ value: { data: '' } }]);
expect(store.dispatch).toHaveBeenCalledWith('performSearch');
});
it('calls historyPushState', () => {
commonUtils.historyPushState = jest.fn();
wrapper
.find(FilteredSearchBarRoot)
.vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
expect(commonUtils.historyPushState).toHaveBeenCalledWith(
'http://test.host/?search=searchQuery',
);
});
});
});
});
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