Commit 977830e2 authored by Kushal Pandya's avatar Kushal Pandya

Add filtered search bar with history & sort support

Adds a shared wrapper component for GlFilteredSearch which
supports Recent searches history and sort dropdown.
parent 461d574c
export const ANY_AUTHOR = 'Any';
export const DEBOUNCE_DELAY = 200;
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
<script>
import {
GlFilteredSearch,
GlButtonGroup,
GlButton,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
GlTooltipDirective,
} from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import { SortDirection } from './constants';
export default {
components: {
GlFilteredSearch,
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
namespace: {
type: String,
required: true,
},
recentSearchesStorageKey: {
type: String,
required: false,
default: '',
},
tokens: {
type: Array,
required: true,
},
sortOptions: {
type: Array,
required: true,
},
initialFilterValue: {
type: Array,
required: false,
default: () => [],
},
initialSortBy: {
type: String,
required: false,
default: '',
validator: value => value === '' || /(_desc)|(_asc)/g.test(value),
},
searchInputPlaceholder: {
type: String,
required: true,
},
},
data() {
let selectedSortOption = this.sortOptions[0].sortDirection.descending;
let selectedSortDirection = SortDirection.descending;
// Extract correct sortBy value based on initialSortBy
if (this.initialSortBy) {
selectedSortOption = this.sortOptions
.filter(
sortBy =>
sortBy.sortDirection.ascending === this.initialSortBy ||
sortBy.sortDirection.descending === this.initialSortBy,
)
.pop();
selectedSortDirection = this.initialSortBy.endsWith('_desc')
? SortDirection.descending
: SortDirection.ascending;
}
return {
initialRender: true,
recentSearchesPromise: null,
filterValue: this.initialFilterValue,
selectedSortOption,
selectedSortDirection,
};
},
computed: {
tokenSymbols() {
return this.tokens.reduce(
(tokenSymbols, token) => ({
...tokenSymbols,
[token.type]: token.symbol,
}),
{},
);
},
sortDirectionIcon() {
return this.selectedSortDirection === SortDirection.ascending
? 'sort-lowest'
: 'sort-highest';
},
sortDirectionTooltip() {
return this.selectedSortDirection === SortDirection.ascending
? __('Sort direction: Ascending')
: __('Sort direction: Descending');
},
},
watch: {
/**
* GlFilteredSearch currently doesn't emit any event when
* search field is cleared, but we still want our parent
* component to know that filters were cleared and do
* necessary data refetch, so this watcher is basically
* a dirty hack/workaround to identify if filter input
* was cleared. :(
*/
filterValue(value) {
const [firstVal] = value;
if (
!this.initialRender &&
value.length === 1 &&
firstVal.type === 'filtered-search-term' &&
!firstVal.value.data
) {
this.$emit('onFilter', []);
}
// Set initial render flag to false
// as we don't want to emit event
// on initial load when value is empty already.
this.initialRender = false;
},
},
created() {
if (this.recentSearchesStorageKey) this.setupRecentSearch();
},
methods: {
/**
* Initialize service and store instances for
* getting Recent Search functional.
*/
setupRecentSearch() {
this.recentSearchesService = new RecentSearchesService(
`${this.namespace}-${RecentSearchesStorageKeys[this.recentSearchesStorageKey]}`,
);
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.tokens.map(token => token.type),
});
this.recentSearchesPromise = this.recentSearchesService
.fetch()
.catch(error => {
if (error.name === 'RecentSearchesServiceError') return undefined;
createFlash(__('An error occurred while parsing recent searches'));
// Gracefully fail to empty array
return [];
})
.then(searches => {
if (!searches) return;
// Put any searches that may have come in before
// we fetched the saved searches ahead of the already saved ones
const resultantSearches = this.recentSearchesStore.setRecentSearches(
this.recentSearchesStore.state.recentSearches.concat(searches),
);
this.recentSearchesService.save(resultantSearches);
});
},
getRecentSearches() {
return this.recentSearchesStore?.state.recentSearches;
},
handleSortOptionClick(sortBy) {
this.selectedSortOption = sortBy;
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
},
handleSortDirectionClick() {
this.selectedSortDirection =
this.selectedSortDirection === SortDirection.ascending
? SortDirection.descending
: SortDirection.ascending;
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
},
handleFilterSubmit(filters) {
if (this.recentSearchesStorageKey) {
this.recentSearchesPromise
.then(() => {
if (filters.length) {
const searchTokens = filters.map(filter => {
// check filter was plain text search
if (typeof filter === 'string') {
return filter;
}
// filter was a token.
return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${
filter.value.data
}`;
});
const resultantSearches = this.recentSearchesStore.addRecentSearch(
searchTokens.join(' '),
);
this.recentSearchesService.save(resultantSearches);
}
})
.catch(() => {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
}
this.$emit('onFilter', filters);
},
},
};
</script>
<template>
<div class="vue-filtered-search-bar-container d-flex">
<gl-filtered-search
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
:history-items="getRecentSearches()"
class="flex-grow-1"
@submit="handleFilterSubmit"
/>
<gl-button-group class="ml-2">
<gl-dropdown :text="selectedSortOption.title" :right="true">
<gl-dropdown-item
v-for="sortBy in sortOptions"
:key="sortBy.id"
:is-check-item="true"
:is-checked="sortBy.id === selectedSortOption.id"
@click="handleSortOptionClick(sortBy)"
>{{ sortBy.title }}</gl-dropdown-item
>
</gl-dropdown>
<gl-button
v-gl-tooltip
:title="sortDirectionTooltip"
:icon="sortDirectionIcon"
@click="handleSortDirectionClick"
/>
</gl-button-group>
</div>
</template>
<script>
import {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants';
export default {
anyAuthor: ANY_AUTHOR,
components: {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
authors: this.config.initialAuthors || [],
loading: true,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeAuthor() {
return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
},
},
methods: {
fetchAuthorBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
? this.config.fetchAuthors(this.config.fetchPath, searchTerm)
: this.config.fetchAuthors(searchTerm);
fetchPromise
.then(res => {
// We'd want to avoid doing this check but
// users.json and /groups/:id/members & /projects/:id/users
// return response differently.
this.authors = Array.isArray(res) ? res : res.data;
})
.catch(() => createFlash(__('There was a problem fetching users.')))
.finally(() => {
this.loading = false;
});
},
searchAuthors: debounce(function debouncedSearch({ data }) {
this.fetchAuthorBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchAuthors"
>
<template #view="{ inputValue }">
<gl-avatar
v-if="activeAuthor"
:size="16"
:src="activeAuthor.avatar_url"
shape="circle"
class="gl-mr-2"
/>
<span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
</template>
<template #suggestions>
<gl-filtered-search-suggestion :value="$options.anyAuthor">{{
__('Any')
}}</gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="author in authors"
:key="author.username"
:value="author.username"
>
<div class="d-flex">
<gl-avatar :size="32" :src="author.avatar_url" />
<div>
<div>{{ author.name }}</div>
<div>@{{ author.username }}</div>
</div>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
...@@ -20467,6 +20467,12 @@ msgstr "" ...@@ -20467,6 +20467,12 @@ msgstr ""
msgid "Sort direction" msgid "Sort direction"
msgstr "" msgstr ""
msgid "Sort direction: Ascending"
msgstr ""
msgid "Sort direction: Descending"
msgstr ""
msgid "SortOptions|Access level, ascending" msgid "SortOptions|Access level, ascending"
msgstr "" msgstr ""
...@@ -22057,6 +22063,9 @@ msgstr "" ...@@ -22057,6 +22063,9 @@ msgstr ""
msgid "There was a problem fetching project users." msgid "There was a problem fetching project users."
msgstr "" msgstr ""
msgid "There was a problem fetching users."
msgstr ""
msgid "There was a problem refreshing the data, please try again" msgid "There was a problem refreshing the data, please try again"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import {
GlFilteredSearch,
GlButtonGroup,
GlButton,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
} from '@gitlab/ui';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import { mockAvailableTokens, mockSortOptions } from './mock_data';
const createComponent = ({
namespace = 'gitlab-org/gitlab-test',
recentSearchesStorageKey = 'requirements',
tokens = mockAvailableTokens,
sortOptions = mockSortOptions,
searchInputPlaceholder = 'Filter requirements',
} = {}) =>
shallowMount(FilteredSearchBarRoot, {
propsData: {
namespace,
recentSearchesStorageKey,
tokens,
sortOptions,
searchInputPlaceholder,
},
});
describe('FilteredSearchBarRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('data', () => {
it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props', () => {
expect(wrapper.vm.filterValue).toEqual([]);
expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending);
expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending);
});
});
describe('computed', () => {
describe('tokenSymbols', () => {
it('returns array of map containing type and symbols from `tokens` prop', () => {
expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' });
});
});
describe('sortDirectionIcon', () => {
it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => {
wrapper.setData({
selectedSortDirection: SortDirection.ascending,
});
expect(wrapper.vm.sortDirectionIcon).toBe('sort-lowest');
});
it('returns string "sort-highest" when `selectedSortDirection` is "descending"', () => {
wrapper.setData({
selectedSortDirection: SortDirection.descending,
});
expect(wrapper.vm.sortDirectionIcon).toBe('sort-highest');
});
});
describe('sortDirectionTooltip', () => {
it('returns string "Sort direction: Ascending" when `selectedSortDirection` is "ascending"', () => {
wrapper.setData({
selectedSortDirection: SortDirection.ascending,
});
expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Ascending');
});
it('returns string "Sort direction: Descending" when `selectedSortDirection` is "descending"', () => {
wrapper.setData({
selectedSortDirection: SortDirection.descending,
});
expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending');
});
});
});
describe('watchers', () => {
describe('filterValue', () => {
it('emits component event `onFilter` with empty array when `filterValue` is cleared by GlFilteredSearch', () => {
wrapper.setData({
initialRender: false,
filterValue: [
{
type: 'filtered-search-term',
value: { data: '' },
},
],
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('onFilter')[0]).toEqual([[]]);
});
});
});
});
describe('methods', () => {
describe('setupRecentSearch', () => {
it('initializes `recentSearchesService` and `recentSearchesStore` props when `recentSearchesStorageKey` is available', () => {
expect(wrapper.vm.recentSearchesService instanceof RecentSearchesService).toBe(true);
expect(wrapper.vm.recentSearchesStore instanceof RecentSearchesStore).toBe(true);
});
it('initializes `recentSearchesPromise` prop with a promise by using `recentSearchesService.fetch()`', () => {
jest
.spyOn(wrapper.vm.recentSearchesService, 'fetch')
.mockReturnValue(new Promise(() => []));
wrapper.vm.setupRecentSearch();
expect(wrapper.vm.recentSearchesPromise instanceof Promise).toBe(true);
});
});
describe('getRecentSearches', () => {
it('returns array of strings representing recent searches', () => {
wrapper.vm.recentSearchesStore.setRecentSearches(['foo']);
expect(wrapper.vm.getRecentSearches()).toEqual(['foo']);
});
});
describe('handleSortOptionClick', () => {
it('emits component event `onSort` with selected sort by value', () => {
wrapper.vm.handleSortOptionClick(mockSortOptions[1]);
expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[1]);
expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]);
});
});
describe('handleSortDirectionClick', () => {
beforeEach(() => {
wrapper.setData({
selectedSortOption: mockSortOptions[0],
});
});
it('sets `selectedSortDirection` to be opposite of its current value', () => {
expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending);
wrapper.vm.handleSortDirectionClick();
expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.ascending);
});
it('emits component event `onSort` with opposite of currently selected sort by value', () => {
wrapper.vm.handleSortDirectionClick();
expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]);
});
});
describe('handleFilterSubmit', () => {
const mockFilters = [
{
type: 'author_username',
value: {
data: 'root',
operator: '=',
},
},
'foo',
];
it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => {
jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch');
// jest.spyOn(wrapper.vm.recentSearchesService, 'save');
wrapper.vm.handleFilterSubmit(mockFilters);
return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(
'author_username:=@root foo',
);
});
});
it('calls `recentSearchesService.save` with array of searches', () => {
jest.spyOn(wrapper.vm.recentSearchesService, 'save');
wrapper.vm.handleFilterSubmit(mockFilters);
return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([
'author_username:=@root foo',
]);
});
});
it('emits component event `onFilter` with provided filters param', () => {
wrapper.vm.handleFilterSubmit(mockFilters);
expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]);
});
});
});
describe('template', () => {
beforeEach(() => {
wrapper.setData({
selectedSortOption: mockSortOptions[0],
selectedSortDirection: SortDirection.descending,
});
return wrapper.vm.$nextTick();
});
it('renders gl-filtered-search component', () => {
const glFilteredSearchEl = wrapper.find(GlFilteredSearch);
expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements');
expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens);
});
it('renders sort dropdown component', () => {
expect(wrapper.find(GlButtonGroup).exists()).toBe(true);
expect(wrapper.find(GlDropdown).exists()).toBe(true);
expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title);
});
it('renders dropdown items', () => {
const dropdownItemsEl = wrapper.findAll(GlDropdownItem);
expect(dropdownItemsEl).toHaveLength(mockSortOptions.length);
expect(dropdownItemsEl.at(0).text()).toBe(mockSortOptions[0].title);
expect(dropdownItemsEl.at(0).props('isChecked')).toBe(true);
expect(dropdownItemsEl.at(1).text()).toBe(mockSortOptions[1].title);
});
it('renders sort direction button', () => {
const sortButtonEl = wrapper.find(GlButton);
expect(sortButtonEl.attributes('title')).toBe('Sort direction: Descending');
expect(sortButtonEl.props('icon')).toBe('sort-highest');
});
});
});
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
export const mockAuthor1 = {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
web_url: 'http://0.0.0.0:3000/root',
};
export const mockAuthor2 = {
id: 2,
name: 'Claudio Beer',
username: 'ericka_terry',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/12a89d115b5a398d5082897ebbcba9c2?s=80&d=identicon',
web_url: 'http://0.0.0.0:3000/ericka_terry',
};
export const mockAuthor3 = {
id: 6,
name: 'Shizue Hartmann',
username: 'junita.weimann',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/9da1abb41b1d4c9c9e81030b71ea61a0?s=80&d=identicon',
web_url: 'http://0.0.0.0:3000/junita.weimann',
};
export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3];
export const mockAuthorToken = {
type: 'author_username',
icon: 'user',
title: 'Author',
unique: false,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: 'gitlab-org/gitlab-test',
fetchAuthors: Api.projectUsers.bind(Api),
};
export const mockAvailableTokens = [mockAuthorToken];
export const mockSortOptions = [
{
id: 1,
title: 'Created date',
sortDirection: {
descending: 'created_desc',
ascending: 'created_asc',
},
},
{
id: 2,
title: 'Last updated',
sortDirection: {
descending: 'updated_desc',
ascending: 'updated_asc',
},
},
];
import { mount } from '@vue/test-utils';
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { mockAuthorToken, mockAuthors } from '../mock_data';
jest.mock('~/flash');
const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) =>
mount(AuthorToken, {
propsData: {
config,
value,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
},
stubs: {
Portal: {
template: '<div><slot></slot></div>',
},
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
},
});
describe('AuthorToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
wrapper.setProps({
value: { data: 'FOO' },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.currentValue).toBe('foo');
});
});
});
describe('activeAuthor', () => {
it('returns object for currently present `value.data`', () => {
wrapper.setData({
authors: mockAuthors,
});
wrapper.setProps({
value: { data: mockAuthors[0].username },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]);
});
});
});
});
describe('fetchAuthorBySearchTerm', () => {
it('calls `config.fetchAuthors` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchAuthors');
wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username);
expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith(
mockAuthorToken.fetchPath,
mockAuthors[0].username,
);
});
it('sets response to `authors` when request is succesful', () => {
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors);
wrapper.vm.fetchAuthorBySearchTerm('root');
return waitForPromises().then(() => {
expect(wrapper.vm.authors).toEqual(mockAuthors);
});
});
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
wrapper.vm.fetchAuthorBySearchTerm('root');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith('There was a problem fetching users.');
});
});
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
wrapper.vm.fetchAuthorBySearchTerm('root');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
});
});
});
describe('template', () => {
beforeEach(() => {
wrapper.setData({
authors: mockAuthors,
});
return wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
});
it('renders token item when value is selected', () => {
wrapper.setProps({
value: { data: mockAuthors[0].username },
});
return wrapper.vm.$nextTick(() => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator"
expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator"
});
});
});
});
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