Commit 0bb768ba authored by Florie Guibert's avatar Florie Guibert Committed by Kushal Pandya

Support reaction emogi for GlFilteredSearch

Make epics filterable by reaction emoji on epics list and roadmap
parent 6a96e3a5
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
emojis: this.config.initialEmojis || [],
defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
loading: true,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeEmoji() {
return this.emojis.find(
(emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue),
);
},
},
methods: {
fetchEmojiBySearchTerm(searchTerm) {
this.loading = true;
this.config
.fetchEmojis(searchTerm)
.then((res) => {
this.emojis = Array.isArray(res) ? res : res.data;
})
.catch(() => createFlash(__('There was a problem fetching emojis.')))
.finally(() => {
this.loading = false;
});
},
searchEmojis: debounce(function debouncedSearch({ data }) {
this.fetchEmojiBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchEmojis"
>
<template #view="{ inputValue }">
<gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" />
<span v-else>{{ inputValue }}</span>
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="emoji in defaultEmojis"
:key="emoji.value"
:value="emoji.value"
>
{{ emoji.value }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultEmojis.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="emoji in emojis"
:key="emoji.name"
:value="emoji.name"
>
<div class="gl-display-flex">
<gl-emoji :data-name="emoji.name" />
<span class="gl-ml-3">{{ emoji.name }}</span>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -8,6 +8,7 @@ query groupEpics(
$authorUsername: String
$labelName: [String!]
$milestoneTitle: String = ""
$myReactionEmoji: String
$confidential: Boolean
$search: String = ""
$sortBy: EpicSort
......@@ -22,6 +23,7 @@ query groupEpics(
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
myReactionEmoji: $myReactionEmoji
confidential: $confidential
search: $search
sort: $sortBy
......
......@@ -5,6 +5,7 @@ import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_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';
......@@ -14,7 +15,7 @@ export default {
inject: ['groupFullPath', 'groupMilestonesPath'],
computed: {
urlParams() {
const { search, authorUsername, labelName, milestoneTitle, confidential } =
const { search, authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji } =
this.filterParams || {};
return {
......@@ -27,13 +28,14 @@ export default {
'label_name[]': labelName,
milestone_title: milestoneTitle,
confidential,
my_reaction_emoji: myReactionEmoji,
search,
};
},
},
methods: {
getFilteredSearchTokens() {
return [
const tokens = [
{
type: 'author_username',
icon: 'user',
......@@ -103,9 +105,35 @@ export default {
],
},
];
if (gon.current_user_id) {
// Appending to tokens only when logged-in
tokens.push({
type: 'my_reaction_emoji',
icon: 'thumb-up',
title: __('My-Reaction'),
unique: true,
token: EmojiToken,
operators: FilterTokenOperators,
fetchEmojis: (search = '') => {
return axios
.get(`${gon.relative_url_root || ''}/-/autocomplete/award_emojis`)
.then(({ data }) => {
if (search) {
return {
data: data.filter((e) => e.name.toLowerCase().includes(search.toLowerCase())),
};
}
return { data };
});
},
});
}
return tokens;
},
getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, search } =
const { authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji, search } =
this.filterParams || {};
const filteredSearchValue = [];
......@@ -139,6 +167,13 @@ export default {
});
}
if (myReactionEmoji) {
filteredSearchValue.push({
type: 'my_reaction_emoji',
value: { data: myReactionEmoji },
});
}
if (search) {
filteredSearchValue.push(search);
}
......@@ -164,6 +199,9 @@ export default {
case 'confidential':
filterParams.confidential = filter.value.data;
break;
case 'my_reaction_emoji':
filterParams.myReactionEmoji = filter.value.data;
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
......
......@@ -9,6 +9,7 @@ query groupEpics(
$labelName: [String!] = []
$authorUsername: String = ""
$milestoneTitle: String = ""
$myReactionEmoji: String
$confidential: Boolean
$search: String = ""
$first: Int = 1001
......@@ -24,6 +25,7 @@ query groupEpics(
labelName: $labelName
authorUsername: $authorUsername
milestoneTitle: $milestoneTitle
myReactionEmoji: $myReactionEmoji
confidential: $confidential
search: $search
first: $first
......
......@@ -50,6 +50,10 @@ module Resolvers
required: false,
description: 'Filter epics by given confidentiality.'
argument :my_reaction_emoji, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by reaction emoji applied by the current user.'
type Types::EpicType, null: true
def ready?(**args)
......
---
title: Support reaction emoji on Epics Roadmap
merge_request: 57452
author:
type: added
......@@ -12,6 +12,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import { visitUrl, mergeUrlParams, updateHistory } 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 EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_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';
......@@ -159,6 +160,53 @@ describe('RoadmapFilters', () => {
];
let filteredSearchBar;
const operators = [{ value: '=', description: 'is', default: 'true' }];
const filterTokens = [
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators,
fetchAuthors: expect.any(Function),
},
{
type: 'label_name',
icon: 'labels',
title: 'Label',
unique: false,
symbol: '~',
token: LabelToken,
operators,
fetchLabels: expect.any(Function),
},
{
type: 'milestone_title',
icon: 'clock',
title: 'Milestone',
unique: true,
symbol: '%',
token: MilestoneToken,
operators,
fetchMilestones: expect.any(Function),
},
{
type: 'confidential',
icon: 'eye-slash',
title: 'Confidential',
unique: true,
token: GlFilteredSearchToken,
operators,
options: [
{ icon: 'eye-slash', value: true, title: 'Yes' },
{ icon: 'eye', value: false, title: 'No' },
],
},
];
beforeEach(() => {
filteredSearchBar = wrapper.find(FilteredSearchBar);
});
......@@ -169,51 +217,8 @@ describe('RoadmapFilters', () => {
expect(filteredSearchBar.props('recentSearchesStorageKey')).toBe('epics');
});
it('includes `Author` and `Label` tokens', () => {
expect(filteredSearchBar.props('tokens')).toEqual([
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchAuthors: expect.any(Function),
},
{
type: 'label_name',
icon: 'labels',
title: 'Label',
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchLabels: expect.any(Function),
},
{
type: 'milestone_title',
icon: 'clock',
title: 'Milestone',
unique: true,
symbol: '%',
token: MilestoneToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchMilestones: expect.any(Function),
},
{
type: 'confidential',
icon: 'eye-slash',
title: 'Confidential',
unique: true,
token: GlFilteredSearchToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
options: [
{ icon: 'eye-slash', value: true, title: 'Yes' },
{ icon: 'eye', value: false, title: 'No' },
],
},
]);
it('includes `Author`, `Milestone`, `Confidential` and `Label` tokens when user is not logged in', () => {
expect(filteredSearchBar.props('tokens')).toEqual(filterTokens);
});
it('includes "Start date" and "Due date" sort options', () => {
......@@ -282,6 +287,27 @@ describe('RoadmapFilters', () => {
expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc');
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
});
describe('when user is logged in', () => {
beforeAll(() => {
gon.current_user_id = 1;
});
it('includes `Author`, `Milestone`, `Confidential`, `Label` and `My-Reaction` tokens', () => {
expect(filteredSearchBar.props('tokens')).toEqual([
...filterTokens,
{
type: 'my_reaction_emoji',
icon: 'thumb-up',
title: 'My-Reaction',
unique: true,
token: EmojiToken,
operators,
fetchEmojis: expect.any(Function),
},
]);
});
});
});
});
});
......@@ -134,6 +134,17 @@ RSpec.describe Resolvers::EpicsResolver do
end
end
context 'with my_reaction_emoji' do
it 'filters epics by reaction emoji' do
create(:award_emoji, name: 'thumbsup', user: current_user, awardable: epic1)
create(:award_emoji, name: 'star', user: current_user, awardable: epic2)
epics = resolve_epics(my_reaction_emoji: 'thumbsup')
expect(epics).to match_array([epic1])
end
end
context 'with milestone_title' do
let_it_be(:milestone1) { create(:milestone, group: group) }
......
......@@ -30687,6 +30687,9 @@ msgstr ""
msgid "There was a problem fetching branches."
msgstr ""
msgid "There was a problem fetching emojis."
msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
......
......@@ -3,6 +3,7 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_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';
......@@ -59,6 +60,16 @@ export const mockMilestones = [
mockEscapedMilestone,
];
export const mockEmoji1 = {
name: 'thumbsup',
};
export const mockEmoji2 = {
name: 'star',
};
export const mockEmojis = [mockEmoji1, mockEmoji2];
export const mockBranchToken = {
type: 'source_branch',
icon: 'branch',
......@@ -103,6 +114,16 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
export const mockReactionEmojiToken = {
type: 'my_reaction_emoji',
icon: 'thumb-up',
title: 'My-Reaction',
unique: true,
token: EmojiToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchEmojis: () => Promise.resolve(mockEmojis),
};
export const mockMembershipToken = {
type: 'with_inherited_permissions',
icon: 'group',
......
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlFilteredSearchTokenSegment,
GlDropdownDivider,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import {
DEFAULT_LABEL_NONE,
DEFAULT_LABEL_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import { mockReactionEmojiToken, mockEmojis } from '../mock_data';
jest.mock('~/flash');
const GlEmoji = { template: '<img/>' };
const defaultStubs = {
Portal: true,
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
GlEmoji,
};
function createComponent(options = {}) {
const {
config = mockReactionEmojiToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(EmojiToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
stubs,
});
}
describe('EmojiToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: mockEmojis[0].name } });
wrapper.setData({
emojis: mockEmojis,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name);
});
});
describe('activeEmoji', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]);
});
});
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('fetchEmojiBySearchTerm', () => {
it('calls `config.fetchEmojis` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis');
wrapper.vm.fetchEmojiBySearchTerm('foo');
expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo');
});
it('sets response to `emojis` when request is successful', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis);
wrapper.vm.fetchEmojiBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.emojis).toEqual(mockEmojis);
});
});
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
wrapper.vm.fetchEmojiBySearchTerm('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith('There was a problem fetching emojis.');
});
});
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
wrapper.vm.fetchEmojiBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
});
});
});
});
describe('template', () => {
const defaultEmojis = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
beforeEach(async () => {
wrapper = createComponent({
value: { data: `"${mockEmojis[0].name}"` },
});
wrapper.setData({
emojis: mockEmojis,
});
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); // My Reaction, =, "thumbsup"
expect(tokenSegments.at(2).find(GlEmoji).attributes('data-name')).toEqual('thumbsup');
});
it('renders provided defaultEmojis as suggestions', async () => {
wrapper = createComponent({
active: true,
config: { ...mockReactionEmojiToken, defaultEmojis },
stubs: { Portal: true, GlEmoji },
});
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(defaultEmojis.length);
defaultEmojis.forEach((emoji, index) => {
expect(suggestions.at(index).text()).toBe(emoji.text);
});
});
it('does not render divider when no defaultEmojis', async () => {
wrapper = createComponent({
active: true,
config: { ...mockReactionEmojiToken, defaultEmojis: [] },
stubs: { Portal: true, GlEmoji },
});
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false);
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
it('renders `DEFAULT_LABEL_NONE` and `DEFAULT_LABEL_ANY` as default suggestions', async () => {
wrapper = createComponent({
active: true,
config: { ...mockReactionEmojiToken },
stubs: { Portal: true, GlEmoji },
});
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(2);
expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_NONE.text);
expect(suggestions.at(1).text()).toBe(DEFAULT_LABEL_ANY.text);
});
});
});
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