Commit d2440650 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'kp-requirements-add-search-history-support' into 'master'

Add search history support for Requirements

See merge request gitlab-org/gitlab!36554
parents 766dad50 0687d0d8
...@@ -9,3 +9,5 @@ export const FILTER_TYPE = { ...@@ -9,3 +9,5 @@ export const FILTER_TYPE = {
none: 'none', none: 'none',
any: 'any', 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 { class RecentSearchesStore {
constructor(initialState = {}, allowedKeys) { constructor(initialState = {}, allowedKeys) {
...@@ -17,8 +19,12 @@ class RecentSearchesStore { ...@@ -17,8 +19,12 @@ class RecentSearchesStore {
} }
setRecentSearches(searches = []) { setRecentSearches(searches = []) {
const trimmedSearches = searches.map(search => search.trim()); const trimmedSearches = searches.map(search =>
this.state.recentSearches = uniq(trimmedSearches).slice(0, 5); 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; return this.state.recentSearches;
} }
} }
......
...@@ -98,6 +98,15 @@ export default { ...@@ -98,6 +98,15 @@ export default {
{}, {},
); );
}, },
tokenTitles() {
return this.tokens.reduce(
(tokenSymbols, token) => ({
...tokenSymbols,
[token.type]: token.title,
}),
{},
);
},
sortDirectionIcon() { sortDirectionIcon() {
return this.selectedSortDirection === SortDirection.ascending return this.selectedSortDirection === SortDirection.ascending
? 'sort-lowest' ? 'sort-lowest'
...@@ -112,11 +121,10 @@ export default { ...@@ -112,11 +121,10 @@ export default {
watch: { watch: {
/** /**
* GlFilteredSearch currently doesn't emit any event when * GlFilteredSearch currently doesn't emit any event when
* search field is cleared, but we still want our parent * tokens are manually removed from search field so we'd
* component to know that filters were cleared and do * never know when user actually clears all the tokens.
* necessary data refetch, so this watcher is basically * This watcher listens for updates to `filterValue` on
* a dirty hack/workaround to identify if filter input * such instances. :(
* was cleared. :(
*/ */
filterValue(value) { filterValue(value) {
const [firstVal] = value; const [firstVal] = value;
...@@ -188,25 +196,16 @@ export default { ...@@ -188,25 +196,16 @@ export default {
: SortDirection.ascending; : SortDirection.ascending;
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
}, },
handleClearHistory() {
const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
this.recentSearchesService.save(resultantSearches);
},
handleFilterSubmit(filters) { handleFilterSubmit(filters) {
if (this.recentSearchesStorageKey) { if (this.recentSearchesStorageKey) {
this.recentSearchesPromise this.recentSearchesPromise
.then(() => { .then(() => {
if (filters.length) { if (filters.length) {
const searchTokens = filters.map(filter => { const resultantSearches = this.recentSearchesStore.addRecentSearch(filters);
// 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); this.recentSearchesService.save(resultantSearches);
} }
}) })
...@@ -228,8 +227,23 @@ export default { ...@@ -228,8 +227,23 @@ export default {
:available-tokens="tokens" :available-tokens="tokens"
:history-items="getRecentSearches()" :history-items="getRecentSearches()"
class="flex-grow-1" class="flex-grow-1"
@history-item-selected="$emit('onFilter', filters)"
@clear-history="handleClearHistory"
@submit="handleFilterSubmit" @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-button-group class="sort-dropdown-container d-flex">
<gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
<gl-dropdown-item <gl-dropdown-item
......
...@@ -46,6 +46,16 @@ export default { ...@@ -46,6 +46,16 @@ export default {
return this.authors.find(author => author.username.toLowerCase() === this.currentValue); 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: { methods: {
fetchAuthorBySearchTerm(searchTerm) { fetchAuthorBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath const fetchPromise = this.config.fetchPath
...@@ -89,9 +99,9 @@ export default { ...@@ -89,9 +99,9 @@ export default {
<span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
</template> </template>
<template #suggestions> <template #suggestions>
<gl-filtered-search-suggestion :value="$options.anyAuthor">{{ <gl-filtered-search-suggestion :value="$options.anyAuthor">
__('Any') {{ __('Any') }}
}}</gl-filtered-search-suggestion> </gl-filtered-search-suggestion>
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-loading-icon v-if="loading" /> <gl-loading-icon v-if="loading" />
<template v-else> <template v-else>
......
...@@ -10,6 +10,7 @@ import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; ...@@ -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 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 AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { ANY_AUTHOR } from '~/vue_shared/components/filtered_search_bar/constants'; 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 RequirementsTabs from './requirements_tabs.vue';
import RequirementsLoading from './requirements_loading.vue'; import RequirementsLoading from './requirements_loading.vue';
...@@ -27,6 +28,7 @@ import { FilterState, AvailableSortOptions, DEFAULT_PAGE_SIZE } from '../constan ...@@ -27,6 +28,7 @@ import { FilterState, AvailableSortOptions, DEFAULT_PAGE_SIZE } from '../constan
export default { export default {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
AvailableSortOptions, AvailableSortOptions,
requirementsRecentSearchesKey: RecentSearchesStorageKeys.requirements,
components: { components: {
GlPagination, GlPagination,
FilteredSearchBar, FilteredSearchBar,
...@@ -524,6 +526,7 @@ export default { ...@@ -524,6 +526,7 @@ export default {
:sort-options="$options.AvailableSortOptions" :sort-options="$options.AvailableSortOptions"
:initial-filter-value="getFilteredSearchValue()" :initial-filter-value="getFilteredSearchValue()"
:initial-sort-by="sortBy" :initial-sort-by="sortBy"
:recent-searches-storage-key="$options.requirementsRecentSearchesKey"
class="row-content-block" class="row-content-block"
@onFilter="handleFilterRequirements" @onFilter="handleFilterRequirements"
@onSort="handleSortRequirements" @onSort="handleSortRequirements"
......
---
title: Add search history support for Requirements
merge_request: 36554
author:
type: added
...@@ -765,6 +765,9 @@ describe('RequirementsRoot', () => { ...@@ -765,6 +765,9 @@ describe('RequirementsRoot', () => {
fetchAuthors: expect.any(Function), fetchAuthors: expect.any(Function),
}, },
]); ]);
expect(wrapper.find(FilteredSearchBarRoot).props('recentSearchesStorageKey')).toBe(
'requirements-recent-searches',
);
}); });
it('renders empty state when query results are empty', () => { it('renders empty state when query results are empty', () => {
......
...@@ -44,6 +44,15 @@ describe('RecentSearchesStore', () => { ...@@ -44,6 +44,15 @@ describe('RecentSearchesStore', () => {
expect(store.state.recentSearches).toEqual(['baz', 'qux']); 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', () => { it('only keeps track of 5 items', () => {
store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']); store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']);
......
...@@ -13,7 +13,7 @@ import { SortDirection } from '~/vue_shared/components/filtered_search_bar/const ...@@ -13,7 +13,7 @@ import { SortDirection } from '~/vue_shared/components/filtered_search_bar/const
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import { mockAvailableTokens, mockSortOptions } from './mock_data'; import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data';
const createComponent = ({ const createComponent = ({
namespace = 'gitlab-org/gitlab-test', namespace = 'gitlab-org/gitlab-test',
...@@ -53,11 +53,17 @@ describe('FilteredSearchBarRoot', () => { ...@@ -53,11 +53,17 @@ describe('FilteredSearchBarRoot', () => {
describe('computed', () => { describe('computed', () => {
describe('tokenSymbols', () => { 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: '@' }); 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', () => { describe('sortDirectionIcon', () => {
it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => {
wrapper.setData({ wrapper.setData({
...@@ -172,6 +178,19 @@ describe('FilteredSearchBarRoot', () => { ...@@ -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', () => { describe('handleFilterSubmit', () => {
const mockFilters = [ const mockFilters = [
{ {
...@@ -186,14 +205,11 @@ describe('FilteredSearchBarRoot', () => { ...@@ -186,14 +205,11 @@ describe('FilteredSearchBarRoot', () => {
it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => {
jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch');
// jest.spyOn(wrapper.vm.recentSearchesService, 'save');
wrapper.vm.handleFilterSubmit(mockFilters); wrapper.vm.handleFilterSubmit(mockFilters);
return wrapper.vm.recentSearchesPromise.then(() => { return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith( expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters);
'author_username:=@root foo',
);
}); });
}); });
...@@ -203,9 +219,7 @@ describe('FilteredSearchBarRoot', () => { ...@@ -203,9 +219,7 @@ describe('FilteredSearchBarRoot', () => {
wrapper.vm.handleFilterSubmit(mockFilters); wrapper.vm.handleFilterSubmit(mockFilters);
return wrapper.vm.recentSearchesPromise.then(() => { return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([ expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]);
'author_username:=@root foo',
]);
}); });
}); });
...@@ -224,6 +238,8 @@ describe('FilteredSearchBarRoot', () => { ...@@ -224,6 +238,8 @@ describe('FilteredSearchBarRoot', () => {
selectedSortDirection: SortDirection.descending, selectedSortDirection: SortDirection.descending,
}); });
wrapper.vm.recentSearchesStore.setRecentSearches(mockHistoryItems);
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -232,6 +248,7 @@ describe('FilteredSearchBarRoot', () => { ...@@ -232,6 +248,7 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements'); expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements');
expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens); expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens);
expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
}); });
it('renders sort dropdown component', () => { it('renders sort dropdown component', () => {
......
...@@ -44,6 +44,29 @@ export const mockAuthorToken = { ...@@ -44,6 +44,29 @@ export const mockAuthorToken = {
export const mockAvailableTokens = [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 = [ export const mockSortOptions = [
{ {
id: 1, id: 1,
......
...@@ -11,11 +11,12 @@ import { mockAuthorToken, mockAuthors } from '../mock_data'; ...@@ -11,11 +11,12 @@ import { mockAuthorToken, mockAuthors } from '../mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) => const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) =>
mount(AuthorToken, { mount(AuthorToken, {
propsData: { propsData: {
config, config,
value, value,
active,
}, },
provide: { provide: {
portalName: 'fake target', portalName: 'fake target',
...@@ -51,32 +52,26 @@ describe('AuthorToken', () => { ...@@ -51,32 +52,26 @@ describe('AuthorToken', () => {
describe('computed', () => { describe('computed', () => {
describe('currentValue', () => { describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => { it('returns lowercase string for `value.data`', () => {
wrapper.setProps({ wrapper = createComponent({ value: { data: 'FOO' } });
value: { data: 'FOO' },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.currentValue).toBe('foo'); expect(wrapper.vm.currentValue).toBe('foo');
}); });
}); });
});
describe('activeAuthor', () => { 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({ wrapper.setData({
authors: mockAuthors, authors: mockAuthors,
}); });
wrapper.setProps({ await wrapper.vm.$nextTick();
value: { data: mockAuthors[0].username },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]);
}); });
}); });
}); });
});
describe('fetchAuthorBySearchTerm', () => { describe('fetchAuthorBySearchTerm', () => {
it('calls `config.fetchAuthors` with provided searchTerm param', () => { it('calls `config.fetchAuthors` with provided searchTerm param', () => {
......
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