Commit be7a6b4b authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch 'kp-add-milestone-token-filtered-search-bar' into 'master'

Add Milestone Token to use with Filtered Search Bar

See merge request gitlab-org/gitlab!39304
parents 6e1a025f e52961ed
import { __ } from '~/locale';
export const ANY_AUTHOR = 'Any';
export const NO_LABEL = 'No label';
......@@ -8,3 +10,14 @@ export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
export const defaultMilestones = [
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'None', text: __('None') },
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'Any', text: __('Any') },
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'Upcoming', text: __('Upcoming') },
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'Started', text: __('Started') },
];
......@@ -15,6 +15,7 @@ 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 { stripQuotes } from './filtered_search_utils';
import { SortDirection } from './constants';
export default {
......@@ -203,6 +204,26 @@ export default {
searchInputEl.blur();
}
},
/**
* This method removes quotes enclosure from filter values which are
* done by `GlFilteredSearch` internally when filter value contains
* spaces.
*/
removeQuotesEnclosure(filters = []) {
return filters.map(filter => {
if (typeof filter === 'object') {
const valueString = filter.value.data;
return {
...filter,
value: {
data: stripQuotes(valueString),
operator: filter.value.operator,
},
};
}
return filter;
});
},
handleSortOptionClick(sortBy) {
this.selectedSortOption = sortBy;
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
......@@ -215,7 +236,7 @@ export default {
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
},
handleHistoryItemSelected(filters) {
this.$emit('onFilter', filters);
this.$emit('onFilter', this.removeQuotesEnclosure(filters));
},
handleClearHistory() {
const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
......@@ -237,7 +258,7 @@ export default {
});
}
this.blurSearchInput();
this.$emit('onFilter', filters);
this.$emit('onFilter', this.removeQuotesEnclosure(filters));
},
},
};
......
// eslint-disable-next-line import/prefer-default-export
export const stripQuotes = value => {
return value.includes(' ') ? value.slice(1, -1) : value;
};
......@@ -13,6 +13,7 @@ import { __ } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { stripQuotes } from '../filtered_search_utils';
import { NO_LABEL, DEBOUNCE_DELAY } from '../constants';
export default {
......@@ -45,12 +46,9 @@ export default {
return this.value.data.toLowerCase();
},
activeLabel() {
// Strip double quotes
const strippedCurrentValue = this.currentValue.includes(' ')
? this.currentValue.substring(1, this.currentValue.length - 1)
: this.currentValue;
return this.labels.find(label => label.title.toLowerCase() === strippedCurrentValue);
return this.labels.find(
label => label.title.toLowerCase() === stripQuotes(this.currentValue),
);
},
containerStyle() {
if (this.activeLabel) {
......
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlNewDropdownDivider as GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { stripQuotes } from '../filtered_search_utils';
import { defaultMilestones, DEBOUNCE_DELAY } from '../constants';
export default {
defaultMilestones,
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
milestones: this.config.initialMilestones || [],
loading: true,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeMilestone() {
return this.milestones.find(
milestone => milestone.title.toLowerCase() === stripQuotes(this.currentValue),
);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.milestones.length) {
this.fetchMilestoneBySearchTerm(this.value.data);
}
},
},
},
methods: {
fetchMilestoneBySearchTerm(searchTerm = '') {
this.loading = true;
this.config
.fetchMilestones(searchTerm)
.then(({ data }) => {
this.milestones = data;
})
.catch(() => createFlash(__('There was a problem fetching milestones.')))
.finally(() => {
this.loading = false;
});
},
searchMilestones: debounce(function debouncedSearch({ data }) {
this.fetchMilestoneBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchMilestones"
>
<template #view="{ inputValue }">
<span>%{{ activeMilestone ? activeMilestone.title : inputValue }}</span>
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="milestone in $options.defaultMilestones"
:key="milestone.value"
:value="milestone.value"
>{{ milestone.text }}</gl-filtered-search-suggestion
>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="milestone in milestones"
:key="milestone.id"
:value="milestone.title"
>
<div>{{ milestone.title }}</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -24470,6 +24470,9 @@ msgstr ""
msgid "There was a problem fetching labels."
msgstr ""
msgid "There was a problem fetching milestones."
msgstr ""
msgid "There was a problem fetching project branches."
msgstr ""
......
......@@ -171,6 +171,46 @@ describe('FilteredSearchBarRoot', () => {
});
});
describe('removeQuotesEnclosure', () => {
const mockFilters = [
{
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', () => {
expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([
{
type: 'author_username',
value: {
data: 'root',
operator: '=',
},
},
{
type: 'label_name',
value: {
data: 'Documentation Update',
operator: '=',
},
},
'foo',
]);
});
});
describe('handleSortOptionClick', () => {
it('emits component event `onSort` with selected sort by value', () => {
wrapper.vm.handleSortOptionClick(mockSortOptions[1]);
......@@ -204,9 +244,12 @@ describe('FilteredSearchBarRoot', () => {
describe('handleHistoryItemSelected', () => {
it('emits `onFilter` event with provided filters param', () => {
jest.spyOn(wrapper.vm, 'removeQuotesEnclosure');
wrapper.vm.handleHistoryItemSelected(mockHistoryItems[0]);
expect(wrapper.emitted('onFilter')[0]).toEqual([mockHistoryItems[0]]);
expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockHistoryItems[0]);
});
});
......@@ -274,9 +317,12 @@ describe('FilteredSearchBarRoot', () => {
});
it('emits component event `onFilter` with provided filters param', () => {
jest.spyOn(wrapper.vm, 'removeQuotesEnclosure');
wrapper.vm.handleFilterSubmit(mockFilters);
expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]);
expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters);
});
});
});
......
import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
describe('Filtered Search Utils', () => {
describe('stripQuotes', () => {
it.each`
inputValue | outputValue
${'"Foo Bar"'} | ${'Foo Bar'}
${"'Foo Bar'"} | ${'Foo Bar'}
${'FooBar'} | ${'FooBar'}
${"Foo'Bar"} | ${"Foo'Bar"}
${'Foo"Bar'} | ${'Foo"Bar'}
`(
'returns string $outputValue when called with string $inputValue',
({ inputValue, outputValue }) => {
expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue);
},
);
});
});
import Api from '~/api';
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 MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
......@@ -33,6 +34,28 @@ export const mockAuthor3 = {
export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3];
export const mockRegularMilestone = {
id: 1,
name: '4.0',
title: '4.0',
};
export const mockEscapedMilestone = {
id: 3,
name: '5.0 RC1',
title: '5.0 RC1',
};
export const mockMilestones = [
{
id: 2,
name: '5.0',
title: '5.0',
},
mockRegularMilestone,
mockEscapedMilestone,
];
export const mockAuthorToken = {
type: 'author_username',
icon: 'user',
......@@ -56,6 +79,17 @@ export const mockLabelToken = {
fetchLabels: () => Promise.resolve(mockLabels),
};
export const mockMilestoneToken = {
type: 'milestone_title',
icon: 'clock',
title: 'Milestone',
unique: true,
symbol: '%',
token: MilestoneToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
export const mockAvailableTokens = [mockAuthorToken, mockLabelToken];
export const mockHistoryItems = [
......
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 MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import {
mockMilestoneToken,
mockMilestones,
mockRegularMilestone,
mockEscapedMilestone,
} from '../mock_data';
jest.mock('~/flash');
const createComponent = ({
config = mockMilestoneToken,
value = { data: '' },
active = false,
} = {}) =>
mount(MilestoneToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
},
stubs: {
Portal: {
template: '<div><slot></slot></div>',
},
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
},
});
describe('MilestoneToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
// Milestone title with spaces is always enclosed in quotations by component.
wrapper = createComponent({ value: { data: `"${mockEscapedMilestone.title}"` } });
wrapper.setData({
milestones: mockMilestones,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe('"5.0 rc1"');
});
});
describe('activeMilestone', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeMilestone).toEqual(mockEscapedMilestone);
});
});
});
describe('methods', () => {
describe('fetchMilestoneBySearchTerm', () => {
it('calls `config.fetchMilestones` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones');
wrapper.vm.fetchMilestoneBySearchTerm('foo');
expect(wrapper.vm.config.fetchMilestones).toHaveBeenCalledWith('foo');
});
it('sets response to `milestones` when request is successful', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockResolvedValue({
data: mockMilestones,
});
wrapper.vm.fetchMilestoneBySearchTerm();
return waitForPromises().then(() => {
expect(wrapper.vm.milestones).toEqual(mockMilestones);
});
});
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
wrapper.vm.fetchMilestoneBySearchTerm('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.');
});
});
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
wrapper.vm.fetchMilestoneBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
});
});
});
});
describe('template', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } });
wrapper.setData({
milestones: mockMilestones,
});
await 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', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"'
expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1"
});
});
});
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