Commit 02606bb9 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '325630-gl-filtered-search-reaction-emoji' into 'master'

Support reaction emoji for GlFilteredSearch

See merge request gitlab-org/gitlab!57452
parents 2e517a76 0bb768ba
<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) }
......
......@@ -30693,6 +30693,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