Commit 0687d0d8 authored by Kushal Pandya's avatar Kushal Pandya

Add search history support for Requirements

Adds support for saving recent searches in Requirements
parent 3a980767
......@@ -9,3 +9,5 @@ export const FILTER_TYPE = {
none: 'none',
any: 'any',
};
export const MAX_HISTORY_SIZE = 5;
import { uniq } from 'lodash';
import { uniqWith, isEqual } from 'lodash';
import { MAX_HISTORY_SIZE } from '../constants';
class RecentSearchesStore {
constructor(initialState = {}, allowedKeys) {
......@@ -17,8 +19,12 @@ class RecentSearchesStore {
}
setRecentSearches(searches = []) {
const trimmedSearches = searches.map(search => search.trim());
this.state.recentSearches = uniq(trimmedSearches).slice(0, 5);
const trimmedSearches = searches.map(search =>
typeof search === 'string' ? search.trim() : search,
);
// Do object equality check to remove duplicates.
this.state.recentSearches = uniqWith(trimmedSearches, isEqual).slice(0, MAX_HISTORY_SIZE);
return this.state.recentSearches;
}
}
......
......@@ -98,6 +98,15 @@ export default {
{},
);
},
tokenTitles() {
return this.tokens.reduce(
(tokenSymbols, token) => ({
...tokenSymbols,
[token.type]: token.title,
}),
{},
);
},
sortDirectionIcon() {
return this.selectedSortDirection === SortDirection.ascending
? 'sort-lowest'
......@@ -112,11 +121,10 @@ export default {
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. :(
* tokens are manually removed from search field so we'd
* never know when user actually clears all the tokens.
* This watcher listens for updates to `filterValue` on
* such instances. :(
*/
filterValue(value) {
const [firstVal] = value;
......@@ -188,25 +196,16 @@ export default {
: SortDirection.ascending;
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
},
handleClearHistory() {
const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
this.recentSearchesService.save(resultantSearches);
},
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(' '),
);
const resultantSearches = this.recentSearchesStore.addRecentSearch(filters);
this.recentSearchesService.save(resultantSearches);
}
})
......@@ -228,8 +227,23 @@ export default {
:available-tokens="tokens"
:history-items="getRecentSearches()"
class="flex-grow-1"
@history-item-selected="$emit('onFilter', filters)"
@clear-history="handleClearHistory"
@submit="handleFilterSubmit"
/>
@clear="$emit('onFilter', [])"
>
<template #history-item="{ historyItem }">
<template v-for="token in historyItem">
<span v-if="typeof token === 'string'" :key="token" class="gl-px-1">"{{ token }}"</span>
<span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1">
<span v-if="tokenTitles[token.type]"
>{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
>
<strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong>
</span>
</template>
</template>
</gl-filtered-search>
<gl-button-group class="sort-dropdown-container d-flex">
<gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
<gl-dropdown-item
......
......@@ -46,6 +46,16 @@ export default {
return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.authors.length) {
this.fetchAuthorBySearchTerm(this.value.data);
}
},
},
},
methods: {
fetchAuthorBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
......@@ -89,9 +99,9 @@ export default {
<span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
</template>
<template #suggestions>
<gl-filtered-search-suggestion :value="$options.anyAuthor">{{
__('Any')
}}</gl-filtered-search-suggestion>
<gl-filtered-search-suggestion :value="$options.anyAuthor">
{{ __('Any') }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
......
......@@ -10,6 +10,7 @@ import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar 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 { ANY_AUTHOR } from '~/vue_shared/components/filtered_search_bar/constants';
import RecentSearchesStorageKeys from 'ee/filtered_search/recent_searches_storage_keys';
import RequirementsTabs from './requirements_tabs.vue';
import RequirementsLoading from './requirements_loading.vue';
......@@ -27,6 +28,7 @@ import { FilterState, AvailableSortOptions, DEFAULT_PAGE_SIZE } from '../constan
export default {
DEFAULT_PAGE_SIZE,
AvailableSortOptions,
requirementsRecentSearchesKey: RecentSearchesStorageKeys.requirements,
components: {
GlPagination,
FilteredSearchBar,
......@@ -524,6 +526,7 @@ export default {
:sort-options="$options.AvailableSortOptions"
:initial-filter-value="getFilteredSearchValue()"
:initial-sort-by="sortBy"
:recent-searches-storage-key="$options.requirementsRecentSearchesKey"
class="row-content-block"
@onFilter="handleFilterRequirements"
@onSort="handleSortRequirements"
......
---
title: Add search history support for Requirements
merge_request: 36554
author:
type: added
......@@ -765,6 +765,9 @@ describe('RequirementsRoot', () => {
fetchAuthors: expect.any(Function),
},
]);
expect(wrapper.find(FilteredSearchBarRoot).props('recentSearchesStorageKey')).toBe(
'requirements-recent-searches',
);
});
it('renders empty state when query results are empty', () => {
......
......@@ -44,6 +44,15 @@ describe('RecentSearchesStore', () => {
expect(store.state.recentSearches).toEqual(['baz', 'qux']);
});
it('handles non-string values', () => {
store.setRecentSearches(['foo ', { foo: 'bar' }, { foo: 'bar' }, ['foobar']]);
// 1. String values will be trimmed of leading/trailing spaces
// 2. Comparison will account for objects to remove duplicates
// 3. Old behaviour of handling string values stays as it is.
expect(store.state.recentSearches).toEqual(['foo', { foo: 'bar' }, ['foobar']]);
});
it('only keeps track of 5 items', () => {
store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']);
......
......@@ -13,7 +13,7 @@ import { SortDirection } from '~/vue_shared/components/filtered_search_bar/const
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import { mockAvailableTokens, mockSortOptions } from './mock_data';
import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data';
const createComponent = ({
namespace = 'gitlab-org/gitlab-test',
......@@ -53,11 +53,17 @@ describe('FilteredSearchBarRoot', () => {
describe('computed', () => {
describe('tokenSymbols', () => {
it('returns array of map containing type and symbols from `tokens` prop', () => {
it('returns a map containing type and symbols from `tokens` prop', () => {
expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' });
});
});
describe('tokenTitles', () => {
it('returns a map containing type and title from `tokens` prop', () => {
expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author' });
});
});
describe('sortDirectionIcon', () => {
it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => {
wrapper.setData({
......@@ -172,6 +178,19 @@ describe('FilteredSearchBarRoot', () => {
});
});
describe('handleClearHistory', () => {
it('clears search history from recent searches store', () => {
jest.spyOn(wrapper.vm.recentSearchesStore, 'setRecentSearches').mockReturnValue([]);
jest.spyOn(wrapper.vm.recentSearchesService, 'save');
wrapper.vm.handleClearHistory();
expect(wrapper.vm.recentSearchesStore.setRecentSearches).toHaveBeenCalledWith([]);
expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([]);
expect(wrapper.vm.getRecentSearches()).toEqual([]);
});
});
describe('handleFilterSubmit', () => {
const mockFilters = [
{
......@@ -186,14 +205,11 @@ describe('FilteredSearchBarRoot', () => {
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',
);
expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters);
});
});
......@@ -203,9 +219,7 @@ describe('FilteredSearchBarRoot', () => {
wrapper.vm.handleFilterSubmit(mockFilters);
return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([
'author_username:=@root foo',
]);
expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]);
});
});
......@@ -224,6 +238,8 @@ describe('FilteredSearchBarRoot', () => {
selectedSortDirection: SortDirection.descending,
});
wrapper.vm.recentSearchesStore.setRecentSearches(mockHistoryItems);
return wrapper.vm.$nextTick();
});
......@@ -232,6 +248,7 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements');
expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens);
expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
});
it('renders sort dropdown component', () => {
......
......@@ -44,6 +44,29 @@ export const mockAuthorToken = {
export const mockAvailableTokens = [mockAuthorToken];
export const mockHistoryItems = [
[
{
type: 'author_username',
value: {
data: 'toby',
operator: '=',
},
},
'duo',
],
[
{
type: 'author_username',
value: {
data: 'root',
operator: '=',
},
},
'si',
],
];
export const mockSortOptions = [
{
id: 1,
......
......@@ -11,11 +11,12 @@ import { mockAuthorToken, mockAuthors } from '../mock_data';
jest.mock('~/flash');
const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) =>
const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) =>
mount(AuthorToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
......@@ -51,29 +52,23 @@ describe('AuthorToken', () => {
describe('computed', () => {
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
wrapper.setProps({
value: { data: 'FOO' },
});
wrapper = createComponent({ value: { data: 'FOO' } });
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.currentValue).toBe('foo');
});
expect(wrapper.vm.currentValue).toBe('foo');
});
});
describe('activeAuthor', () => {
it('returns object for currently present `value.data`', () => {
it('returns object for currently present `value.data`', async () => {
wrapper = createComponent({ value: { data: mockAuthors[0].username } });
wrapper.setData({
authors: mockAuthors,
});
wrapper.setProps({
value: { data: mockAuthors[0].username },
});
await wrapper.vm.$nextTick();
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]);
});
expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]);
});
});
});
......
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