Commit c6cdef0e authored by Kushal Pandya's avatar Kushal Pandya

Add Label Token to use with Filtered Search Bar

Add LabelToken component to use within Filtered Search
Bar.
parent baf8fccc
export const ANY_AUTHOR = 'Any'; export const ANY_AUTHOR = 'Any';
export const NO_LABEL = 'No label';
export const DEBOUNCE_DELAY = 200; export const DEBOUNCE_DELAY = 200;
export const SortDirection = { export const SortDirection = {
......
...@@ -184,6 +184,21 @@ export default { ...@@ -184,6 +184,21 @@ export default {
this.recentSearches = resultantSearches; this.recentSearches = resultantSearches;
}); });
}, },
/**
* When user hits Enter/Return key while typing tokens, we emit `onFilter`
* event immediately so at that time, we don't want to keep tokens dropdown
* visible on UI so this is essentially a hack which allows us to do that
* until `GlFilteredSearch` natively supports this.
* See this discussion https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421#note_385729546
*/
blurSearchInput() {
const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector(
'.gl-filtered-search-token-segment-input',
);
if (searchInputEl) {
searchInputEl.blur();
}
},
handleSortOptionClick(sortBy) { handleSortOptionClick(sortBy) {
this.selectedSortOption = sortBy; this.selectedSortOption = sortBy;
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
...@@ -217,6 +232,7 @@ export default { ...@@ -217,6 +232,7 @@ export default {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
}); });
} }
this.blurSearchInput();
this.$emit('onFilter', filters); this.$emit('onFilter', filters);
}, },
}, },
...@@ -226,6 +242,7 @@ export default { ...@@ -226,6 +242,7 @@ export default {
<template> <template>
<div class="vue-filtered-search-bar-container d-md-flex"> <div class="vue-filtered-search-bar-container d-md-flex">
<gl-filtered-search <gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue" v-model="filterValue"
:placeholder="searchInputPlaceholder" :placeholder="searchInputPlaceholder"
:available-tokens="tokens" :available-tokens="tokens"
......
<script>
import {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { NO_LABEL, DEBOUNCE_DELAY } from '../constants';
export default {
noLabel: NO_LABEL,
components: {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
labels: this.config.initialLabels || [],
loading: true,
};
},
computed: {
currentValue() {
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);
},
containerStyle() {
if (this.activeLabel) {
const { color, textColor } = convertObjectPropsToCamelCase(this.activeLabel);
return { backgroundColor: color, color: textColor };
}
return {};
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.labels.length) {
this.fetchLabelBySearchTerm(this.value.data);
}
},
},
},
methods: {
fetchLabelBySearchTerm(searchTerm) {
this.loading = true;
this.config
.fetchLabels(searchTerm)
.then(res => {
// We'd want to avoid doing this check but
// labels.json and /groups/:id/labels & /projects/:id/labels
// return response differently.
this.labels = Array.isArray(res) ? res : res.data;
})
.catch(() => createFlash(__('There was a problem fetching labels.')))
.finally(() => {
this.loading = false;
});
},
searchLabels: debounce(function debouncedSearch({ data }) {
this.fetchLabelBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchLabels"
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners">
~{{ activeLabel ? activeLabel.title : inputValue }}
</gl-token>
</template>
<template #suggestions>
<gl-filtered-search-suggestion :value="$options.noLabel">
{{ __('No label') }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title">
<div class="gl-display-flex">
<span
:style="{ backgroundColor: label.color }"
class="gl-display-inline-block mr-2 p-2"
></span>
<div>{{ label.title }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
...@@ -24158,6 +24158,9 @@ msgstr "" ...@@ -24158,6 +24158,9 @@ msgstr ""
msgid "There was a problem fetching groups." msgid "There was a problem fetching groups."
msgstr "" msgstr ""
msgid "There was a problem fetching labels."
msgstr ""
msgid "There was a problem fetching project branches." msgid "There was a problem fetching project branches."
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import { import {
GlFilteredSearch, GlFilteredSearch,
GlButtonGroup, GlButtonGroup,
...@@ -16,13 +16,16 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se ...@@ -16,13 +16,16 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se
import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data';
const createComponent = ({ const createComponent = ({
shallow = true,
namespace = 'gitlab-org/gitlab-test', namespace = 'gitlab-org/gitlab-test',
recentSearchesStorageKey = 'requirements', recentSearchesStorageKey = 'requirements',
tokens = mockAvailableTokens, tokens = mockAvailableTokens,
sortOptions = mockSortOptions, sortOptions = mockSortOptions,
searchInputPlaceholder = 'Filter requirements', searchInputPlaceholder = 'Filter requirements',
} = {}) => } = {}) => {
shallowMount(FilteredSearchBarRoot, { const mountMethod = shallow ? shallowMount : mount;
return mountMethod(FilteredSearchBarRoot, {
propsData: { propsData: {
namespace, namespace,
recentSearchesStorageKey, recentSearchesStorageKey,
...@@ -31,6 +34,7 @@ const createComponent = ({ ...@@ -31,6 +34,7 @@ const createComponent = ({
searchInputPlaceholder, searchInputPlaceholder,
}, },
}); });
};
describe('FilteredSearchBarRoot', () => { describe('FilteredSearchBarRoot', () => {
let wrapper; let wrapper;
...@@ -54,13 +58,13 @@ describe('FilteredSearchBarRoot', () => { ...@@ -54,13 +58,13 @@ 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: '@' }); expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' });
}); });
}); });
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' }); expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' });
}); });
}); });
...@@ -233,6 +237,14 @@ describe('FilteredSearchBarRoot', () => { ...@@ -233,6 +237,14 @@ describe('FilteredSearchBarRoot', () => {
}); });
}); });
it('calls `blurSearchInput` method to remove focus from filter input field', () => {
jest.spyOn(wrapper.vm, 'blurSearchInput');
wrapper.find(GlFilteredSearch).vm.$emit('submit', mockFilters);
expect(wrapper.vm.blurSearchInput).toHaveBeenCalled();
});
it('emits component event `onFilter` with provided filters param', () => { it('emits component event `onFilter` with provided filters param', () => {
wrapper.vm.handleFilterSubmit(mockFilters); wrapper.vm.handleFilterSubmit(mockFilters);
...@@ -260,13 +272,28 @@ describe('FilteredSearchBarRoot', () => { ...@@ -260,13 +272,28 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
}); });
it('renders search history items dropdown with formatting done using token symbols', async () => {
const wrapperFullMount = createComponent({ shallow: false });
wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]);
await wrapperFullMount.vm.$nextTick();
const searchHistoryItemsEl = wrapperFullMount.findAll(
'.gl-search-box-by-click-menu .gl-search-box-by-click-history-item',
);
expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"');
wrapperFullMount.destroy();
});
it('renders sort dropdown component', () => { it('renders sort dropdown component', () => {
expect(wrapper.find(GlButtonGroup).exists()).toBe(true); expect(wrapper.find(GlButtonGroup).exists()).toBe(true);
expect(wrapper.find(GlDropdown).exists()).toBe(true); expect(wrapper.find(GlDropdown).exists()).toBe(true);
expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title); expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title);
}); });
it('renders dropdown items', () => { it('renders sort dropdown items', () => {
const dropdownItemsEl = wrapper.findAll(GlDropdownItem); const dropdownItemsEl = wrapper.findAll(GlDropdownItem);
expect(dropdownItemsEl).toHaveLength(mockSortOptions.length); expect(dropdownItemsEl).toHaveLength(mockSortOptions.length);
......
import Api from '~/api'; import Api from '~/api';
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 LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
export const mockAuthor1 = { export const mockAuthor1 = {
id: 1, id: 1,
...@@ -42,7 +45,18 @@ export const mockAuthorToken = { ...@@ -42,7 +45,18 @@ export const mockAuthorToken = {
fetchAuthors: Api.projectUsers.bind(Api), fetchAuthors: Api.projectUsers.bind(Api),
}; };
export const mockAvailableTokens = [mockAuthorToken]; export const mockLabelToken = {
type: 'label_name',
icon: 'labels',
title: 'Label',
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchLabels: () => Promise.resolve(mockLabels),
};
export const mockAvailableTokens = [mockAuthorToken, mockLabelToken];
export const mockHistoryItems = [ export const mockHistoryItems = [
[ [
...@@ -53,6 +67,13 @@ export const mockHistoryItems = [ ...@@ -53,6 +67,13 @@ export const mockHistoryItems = [
operator: '=', operator: '=',
}, },
}, },
{
type: 'label_name',
value: {
data: 'Bug',
operator: '=',
},
},
'duo', 'duo',
], ],
[ [
......
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 LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import {
mockRegularLabel,
mockLabels,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import { mockLabelToken } from '../mock_data';
jest.mock('~/flash');
const createComponent = ({ config = mockLabelToken, value = { data: '' }, active = false } = {}) =>
mount(LabelToken, {
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('LabelToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
// Label title with spaces is always enclosed in quotations by component.
wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } });
wrapper.setData({
labels: mockLabels,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe('"foo label"');
});
});
describe('activeLabel', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeLabel).toEqual(mockRegularLabel);
});
});
describe('containerStyle', () => {
it('returns object containing `backgroundColor` and `color` properties based on `activeLabel` value', () => {
expect(wrapper.vm.containerStyle).toEqual({
backgroundColor: mockRegularLabel.color,
color: mockRegularLabel.textColor,
});
});
it('returns empty object when `activeLabel` is not set', async () => {
wrapper.setData({
labels: [],
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.containerStyle).toEqual({});
});
});
});
describe('methods', () => {
describe('fetchLabelBySearchTerm', () => {
it('calls `config.fetchLabels` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels');
wrapper.vm.fetchLabelBySearchTerm('foo');
expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo');
});
it('sets response to `labels` when request is succesful', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels);
wrapper.vm.fetchLabelBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.labels).toEqual(mockLabels);
});
});
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
wrapper.vm.fetchLabelBySearchTerm('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith('There was a problem fetching labels.');
});
});
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
wrapper.vm.fetchLabelBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
});
});
});
});
describe('template', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } });
wrapper.setData({
labels: mockLabels,
});
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); // Label, =, "Foo Label"
expect(tokenSegments.at(2).text()).toBe(`~${mockRegularLabel.title}`); // "Foo Label"
expect(
tokenSegments
.at(2)
.find('.gl-token')
.attributes('style'),
).toBe('background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);');
});
});
});
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