Commit 69b9f919 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'kp-filtered-search-remove-duplicates' into 'master'

Prevents duplicate tokens from being applied in Filtered Search Bar

See merge request gitlab-org/gitlab!40955
parents e2aced67 407ab6b7
...@@ -15,7 +15,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; ...@@ -15,7 +15,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
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 { stripQuotes } from './filtered_search_utils'; import { stripQuotes, uniqueTokens } from './filtered_search_utils';
import { SortDirection } from './constants'; import { SortDirection } from './constants';
export default { export default {
...@@ -120,10 +120,31 @@ export default { ...@@ -120,10 +120,31 @@ export default {
? __('Sort direction: Ascending') ? __('Sort direction: Ascending')
: __('Sort direction: Descending'); : __('Sort direction: Descending');
}, },
/**
* This prop fixes a behaviour affecting GlFilteredSearch
* where selecting duplicate token values leads to history
* dropdown also showing that selection.
*/
filteredRecentSearches() { filteredRecentSearches() {
return this.recentSearchesStorageKey if (this.recentSearchesStorageKey) {
? this.recentSearches.filter(item => typeof item !== 'string') const knownItems = [];
: undefined; return this.recentSearches.reduce((historyItems, item) => {
// Only include non-string history items (discard items from legacy search)
if (typeof item !== 'string') {
const sanitizedItem = uniqueTokens(item);
const itemString = JSON.stringify(sanitizedItem);
// Only include items which aren't already part of history
if (!knownItems.includes(itemString)) {
historyItems.push(sanitizedItem);
// We're storing string for comparision as doing direct object compare
// won't work due to object reference not being the same.
knownItems.push(itemString);
}
}
return historyItems;
}, []);
}
return undefined;
}, },
}, },
watch: { watch: {
...@@ -245,12 +266,14 @@ export default { ...@@ -245,12 +266,14 @@ export default {
this.recentSearchesService.save(resultantSearches); this.recentSearchesService.save(resultantSearches);
this.recentSearches = []; this.recentSearches = [];
}, },
handleFilterSubmit(filters) { handleFilterSubmit() {
const filterTokens = uniqueTokens(this.filterValue);
this.filterValue = filterTokens;
if (this.recentSearchesStorageKey) { if (this.recentSearchesStorageKey) {
this.recentSearchesPromise this.recentSearchesPromise
.then(() => { .then(() => {
if (filters.length) { if (filterTokens.length) {
const resultantSearches = this.recentSearchesStore.addRecentSearch(filters); const resultantSearches = this.recentSearchesStore.addRecentSearch(filterTokens);
this.recentSearchesService.save(resultantSearches); this.recentSearchesService.save(resultantSearches);
this.recentSearches = resultantSearches; this.recentSearches = resultantSearches;
} }
...@@ -260,7 +283,7 @@ export default { ...@@ -260,7 +283,7 @@ export default {
}); });
} }
this.blurSearchInput(); this.blurSearchInput();
this.$emit('onFilter', this.removeQuotesEnclosure(filters)); this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens));
}, },
}, },
}; };
......
export const stripQuotes = value => { /**
return value.includes(' ') ? value.slice(1, -1) : value; * Strips enclosing quotations from a string if it has one.
*
* @param {String} value String to strip quotes from
*
* @returns {String} String without any enclosure
*/
export const stripQuotes = value => value.replace(/^('|")(.*)('|")$/, '$2');
/**
* This method removes duplicate tokens from tokens array.
*
* @param {Array} tokens Array of tokens as defined by `GlFilteredSearch`
*
* @returns {Array} Unique array of tokens
*/
export const uniqueTokens = tokens => {
const knownTokens = [];
return tokens.reduce((uniques, token) => {
if (typeof token === 'object' && token.type !== 'filtered-search-term') {
const tokenString = `${token.type}${token.value.operator}${token.value.data}`;
if (!knownTokens.includes(tokenString)) {
uniques.push(token);
knownTokens.push(tokenString);
}
} else {
uniques.push(token);
}
return uniques;
}, []);
}; };
...@@ -8,12 +8,27 @@ import { ...@@ -8,12 +8,27 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
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 { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants';
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, mockHistoryItems } from './mock_data'; import {
mockAvailableTokens,
mockSortOptions,
mockHistoryItems,
tokenValueAuthor,
tokenValueLabel,
tokenValueMilestone,
} from './mock_data';
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
uniqueTokens: jest.fn().mockImplementation(tokens => tokens),
stripQuotes: jest.requireActual(
'~/vue_shared/components/filtered_search_bar/filtered_search_utils',
).stripQuotes,
}));
const createComponent = ({ const createComponent = ({
shallow = true, shallow = true,
...@@ -73,13 +88,21 @@ describe('FilteredSearchBarRoot', () => { ...@@ -73,13 +88,21 @@ describe('FilteredSearchBarRoot', () => {
describe('computed', () => { describe('computed', () => {
describe('tokenSymbols', () => { describe('tokenSymbols', () => {
it('returns a 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: '@', label_name: '~' }); expect(wrapper.vm.tokenSymbols).toEqual({
author_username: '@',
label_name: '~',
milestone_title: '%',
});
}); });
}); });
describe('tokenTitles', () => { describe('tokenTitles', () => {
it('returns a map containing type and title from `tokens` prop', () => { it('returns a map containing type and title from `tokens` prop', () => {
expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' }); expect(wrapper.vm.tokenTitles).toEqual({
author_username: 'Author',
label_name: 'Label',
milestone_title: 'Milestone',
});
}); });
}); });
...@@ -131,6 +154,20 @@ describe('FilteredSearchBarRoot', () => { ...@@ -131,6 +154,20 @@ describe('FilteredSearchBarRoot', () => {
expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' }); expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' });
}); });
it('returns array of recent searches sanitizing any duplicate token values', async () => {
wrapper.setData({
recentSearches: [
[tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValueLabel],
[tokenValueAuthor, tokenValueMilestone],
],
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredRecentSearches).toHaveLength(2);
expect(uniqueTokens).toHaveBeenCalled();
});
it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => { it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => {
wrapper.setProps({ wrapper.setProps({
recentSearchesStorageKey: '', recentSearchesStorageKey: '',
...@@ -182,40 +219,12 @@ describe('FilteredSearchBarRoot', () => { ...@@ -182,40 +219,12 @@ describe('FilteredSearchBarRoot', () => {
}); });
describe('removeQuotesEnclosure', () => { describe('removeQuotesEnclosure', () => {
const mockFilters = [ const mockFilters = [tokenValueAuthor, tokenValueLabel, 'foo'];
{
type: 'author_username',
value: {
data: 'root',
operator: '=',
},
},
{
type: 'label_name',
value: {
data: '"Documentation Update"',
operator: '=',
},
},
'foo',
];
it('returns filter array with unescaped strings for values which have spaces', () => { it('returns filter array with unescaped strings for values which have spaces', () => {
expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([ expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([
{ tokenValueAuthor,
type: 'author_username', tokenValueLabel,
value: {
data: 'root',
operator: '=',
},
},
{
type: 'label_name',
value: {
data: 'Documentation Update',
operator: '=',
},
},
'foo', 'foo',
]); ]);
}); });
...@@ -277,21 +286,26 @@ describe('FilteredSearchBarRoot', () => { ...@@ -277,21 +286,26 @@ describe('FilteredSearchBarRoot', () => {
}); });
describe('handleFilterSubmit', () => { describe('handleFilterSubmit', () => {
const mockFilters = [ const mockFilters = [tokenValueAuthor, 'foo'];
{
type: 'author_username', beforeEach(async () => {
value: { wrapper.setData({
data: 'root', filterValue: mockFilters,
operator: '=', });
},
}, await wrapper.vm.$nextTick();
'foo', });
];
it('calls `uniqueTokens` on `filterValue` prop to remove duplicates', () => {
wrapper.vm.handleFilterSubmit();
expect(uniqueTokens).toHaveBeenCalledWith(wrapper.vm.filterValue);
});
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');
wrapper.vm.handleFilterSubmit(mockFilters); wrapper.vm.handleFilterSubmit();
return wrapper.vm.recentSearchesPromise.then(() => { return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters); expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters);
...@@ -301,7 +315,7 @@ describe('FilteredSearchBarRoot', () => { ...@@ -301,7 +315,7 @@ describe('FilteredSearchBarRoot', () => {
it('calls `recentSearchesService.save` with array of searches', () => { it('calls `recentSearchesService.save` with array of searches', () => {
jest.spyOn(wrapper.vm.recentSearchesService, 'save'); jest.spyOn(wrapper.vm.recentSearchesService, 'save');
wrapper.vm.handleFilterSubmit(mockFilters); wrapper.vm.handleFilterSubmit();
return wrapper.vm.recentSearchesPromise.then(() => { return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]); expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]);
...@@ -311,7 +325,7 @@ describe('FilteredSearchBarRoot', () => { ...@@ -311,7 +325,7 @@ describe('FilteredSearchBarRoot', () => {
it('sets `recentSearches` data prop with array of searches', () => { it('sets `recentSearches` data prop with array of searches', () => {
jest.spyOn(wrapper.vm.recentSearchesService, 'save'); jest.spyOn(wrapper.vm.recentSearchesService, 'save');
wrapper.vm.handleFilterSubmit(mockFilters); wrapper.vm.handleFilterSubmit();
return wrapper.vm.recentSearchesPromise.then(() => { return wrapper.vm.recentSearchesPromise.then(() => {
expect(wrapper.vm.recentSearches).toEqual([mockFilters]); expect(wrapper.vm.recentSearches).toEqual([mockFilters]);
...@@ -329,7 +343,7 @@ describe('FilteredSearchBarRoot', () => { ...@@ -329,7 +343,7 @@ describe('FilteredSearchBarRoot', () => {
it('emits component event `onFilter` with provided filters param', () => { it('emits component event `onFilter` with provided filters param', () => {
jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); jest.spyOn(wrapper.vm, 'removeQuotesEnclosure');
wrapper.vm.handleFilterSubmit(mockFilters); wrapper.vm.handleFilterSubmit();
expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]);
expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters); expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters);
...@@ -366,7 +380,9 @@ describe('FilteredSearchBarRoot', () => { ...@@ -366,7 +380,9 @@ describe('FilteredSearchBarRoot', () => {
'.gl-search-box-by-click-menu .gl-search-box-by-click-history-item', '.gl-search-box-by-click-menu .gl-search-box-by-click-history-item',
); );
expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"'); expect(searchHistoryItemsEl.at(0).text()).toBe(
'Author := @rootLabel := ~bugMilestone := %v1.0"duo"',
);
wrapperFullMount.destroy(); wrapperFullMount.destroy();
}); });
......
import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
tokenValueAuthor,
tokenValueLabel,
tokenValueMilestone,
tokenValuePlain,
} from './mock_data';
describe('Filtered Search Utils', () => { describe('Filtered Search Utils', () => {
describe('stripQuotes', () => { describe('stripQuotes', () => {
it.each` it.each`
...@@ -9,6 +16,7 @@ describe('Filtered Search Utils', () => { ...@@ -9,6 +16,7 @@ describe('Filtered Search Utils', () => {
${'FooBar'} | ${'FooBar'} ${'FooBar'} | ${'FooBar'}
${"Foo'Bar"} | ${"Foo'Bar"} ${"Foo'Bar"} | ${"Foo'Bar"}
${'Foo"Bar'} | ${'Foo"Bar'} ${'Foo"Bar'} | ${'Foo"Bar'}
${'Foo Bar'} | ${'Foo Bar'}
`( `(
'returns string $outputValue when called with string $inputValue', 'returns string $outputValue when called with string $inputValue',
({ inputValue, outputValue }) => { ({ inputValue, outputValue }) => {
...@@ -16,4 +24,29 @@ describe('Filtered Search Utils', () => { ...@@ -16,4 +24,29 @@ describe('Filtered Search Utils', () => {
}, },
); );
}); });
describe('uniqueTokens', () => {
it('returns tokens array with duplicates removed', () => {
expect(
filteredSearchUtils.uniqueTokens([
tokenValueAuthor,
tokenValueLabel,
tokenValueMilestone,
tokenValueLabel,
tokenValuePlain,
]),
).toHaveLength(4); // Removes 2nd instance of tokenValueLabel
});
it('returns tokens array as it is if it does not have duplicates', () => {
expect(
filteredSearchUtils.uniqueTokens([
tokenValueAuthor,
tokenValueLabel,
tokenValueMilestone,
tokenValuePlain,
]),
).toHaveLength(4);
});
});
}); });
...@@ -89,36 +89,40 @@ export const mockMilestoneToken = { ...@@ -89,36 +89,40 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }), fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
}; };
export const mockAvailableTokens = [mockAuthorToken, mockLabelToken]; export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken];
export const tokenValueAuthor = {
type: 'author_username',
value: {
data: 'root',
operator: '=',
},
};
export const tokenValueLabel = {
type: 'label_name',
value: {
operator: '=',
data: 'bug',
},
};
export const tokenValueMilestone = {
type: 'milestone_title',
value: {
operator: '=',
data: 'v1.0',
},
};
export const tokenValuePlain = {
type: 'filtered-search-term',
value: { data: 'foo' },
};
export const mockHistoryItems = [ export const mockHistoryItems = [
[ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'],
{ [tokenValueAuthor, 'si'],
type: 'author_username',
value: {
data: 'toby',
operator: '=',
},
},
{
type: 'label_name',
value: {
data: 'Bug',
operator: '=',
},
},
'duo',
],
[
{
type: 'author_username',
value: {
data: 'root',
operator: '=',
},
},
'si',
],
]; ];
export const mockSortOptions = [ export const mockSortOptions = [
......
...@@ -157,7 +157,7 @@ describe('MilestoneToken', () => { ...@@ -157,7 +157,7 @@ describe('MilestoneToken', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"' expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"'
expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1" expect(tokenSegments.at(2).text()).toBe(`%${mockRegularMilestone.title}`); // "4.0 RC1"
}); });
it('renders provided defaultMilestones as suggestions', async () => { it('renders provided defaultMilestones as suggestions', async () => {
......
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