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 { ...@@ -45,6 +45,9 @@ export default {
activeAuthor() { activeAuthor() {
return this.authors.find((author) => author.username.toLowerCase() === this.currentValue); return this.authors.find((author) => author.username.toLowerCase() === this.currentValue);
}, },
activeAuthorAvatar() {
return this.avatarUrl(this.activeAuthor);
},
}, },
watch: { watch: {
active: { active: {
...@@ -74,6 +77,9 @@ export default { ...@@ -74,6 +77,9 @@ export default {
this.loading = false; this.loading = false;
}); });
}, },
avatarUrl(author) {
return author.avatarUrl || author.avatar_url;
},
searchAuthors: debounce(function debouncedSearch({ data }) { searchAuthors: debounce(function debouncedSearch({ data }) {
this.fetchAuthorBySearchTerm(data); this.fetchAuthorBySearchTerm(data);
}, DEBOUNCE_DELAY), }, DEBOUNCE_DELAY),
...@@ -92,7 +98,7 @@ export default { ...@@ -92,7 +98,7 @@ export default {
<gl-avatar <gl-avatar
v-if="activeAuthor" v-if="activeAuthor"
:size="16" :size="16"
:src="activeAuthor.avatar_url" :src="activeAuthorAvatar"
shape="circle" shape="circle"
class="gl-mr-2" class="gl-mr-2"
/> />
...@@ -115,7 +121,7 @@ export default { ...@@ -115,7 +121,7 @@ export default {
:value="author.username" :value="author.username"
> >
<div class="d-flex"> <div class="d-flex">
<gl-avatar :size="32" :src="author.avatar_url" /> <gl-avatar :size="32" :src="avatarUrl(author)" />
<div> <div>
<div>{{ author.name }}</div> <div>{{ author.name }}</div>
<div>@{{ author.username }}</div> <div>@{{ author.username }}</div>
......
<script> <script>
import { mapActions } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { historyPushState } from '~/lib/utils/common_utils'; import { historyPushState } from '~/lib/utils/common_utils';
import { 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 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 { export default {
i18n: { i18n: {
...@@ -12,14 +16,58 @@ export default { ...@@ -12,14 +16,58 @@ export default {
components: { FilteredSearch }, components: { FilteredSearch },
inject: ['search'], inject: ['search'],
computed: { computed: {
...mapState(['fullPath']),
initialSearch() { initialSearch() {
return [{ type: 'filtered-search-term', value: { data: this.search } }]; 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: { methods: {
...mapActions(['performSearch']), ...mapActions(['performSearch']),
tokens() { fetchAuthors(authorsSearchTerm) {
return []; 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 = []) { handleSearch(filters = []) {
const [item] = filters; const [item] = filters;
...@@ -35,9 +83,10 @@ export default { ...@@ -35,9 +83,10 @@ export default {
<template> <template>
<filtered-search <filtered-search
data-testid="epic-filtered-search"
class="gl-w-full" class="gl-w-full"
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="initialSearch"
@onFilter="handleSearch" @onFilter="handleSearch"
......
...@@ -3,7 +3,7 @@ import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_sea ...@@ -3,7 +3,7 @@ import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_sea
import store from '~/boards/stores'; import store from '~/boards/stores';
import { queryToObject } from '~/lib/utils/url_utility'; import { queryToObject } from '~/lib/utils/url_utility';
export default () => { export default (apolloProvider) => {
const queryParams = queryToObject(window.location.search); const queryParams = queryToObject(window.location.search);
const el = document.getElementById('js-board-filtered-search'); const el = document.getElementById('js-board-filtered-search');
...@@ -17,7 +17,7 @@ export default () => { ...@@ -17,7 +17,7 @@ export default () => {
search: queryParams?.search || '', search: queryParams?.search || '',
}, },
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider: {}, apolloProvider,
render: (createElement) => createElement(EpicFilteredSearch), 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 () => { ...@@ -44,7 +44,7 @@ export default () => {
if (gon?.features?.boardsFilteredSearch) { if (gon?.features?.boardsFilteredSearch) {
import('ee/boards/epic_filtered_search') import('ee/boards/epic_filtered_search')
.then(({ default: initFilteredSearch }) => { .then(({ default: initFilteredSearch }) => {
initFilteredSearch(); initFilteredSearch(apolloProvider);
}) })
.catch(() => {}); .catch(() => {});
} }
......
...@@ -158,6 +158,33 @@ RSpec.describe 'epic boards', :js do ...@@ -158,6 +158,33 @@ RSpec.describe 'epic boards', :js do
end end
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 def visit_epic_boards_page
visit group_epic_boards_path(group) visit group_epic_boards_path(group)
wait_for_requests wait_for_requests
......
...@@ -3,7 +3,10 @@ import Vuex from 'vuex'; ...@@ -3,7 +3,10 @@ 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 commonUtils from '~/lib/utils/common_utils';
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 LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -44,6 +47,33 @@ describe('EpicFilteredSearch', () => { ...@@ -44,6 +47,33 @@ describe('EpicFilteredSearch', () => {
expect(findFilteredSearch().exists()).toBe(true); 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', () => { describe('when onFilter is emitted', () => {
it('calls performSearch', () => { it('calls performSearch', () => {
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]); 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