Commit 11eaff63 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'add-reaction-filter-ee' into 'master'

Add my-reaction filter (EE)

Closes #1938

See merge request !2778
parents 721bc0c4 e68c7a41
...@@ -85,6 +85,13 @@ class DropDown { ...@@ -85,6 +85,13 @@ class DropDown {
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join(''); renderableList.innerHTML = children.join('');
const listEvent = new CustomEvent('render.dl', {
detail: {
list: this,
},
});
this.list.dispatchEvent(listEvent);
} }
renderChildren(data) { renderChildren(data) {
......
/* global Flash */
import Ajax from '~/droplab/plugins/ajax';
import Filter from '~/droplab/plugins/filter';
import './filtered_search_dropdown';
class DropdownEmoji extends gl.FilteredSearchDropdown {
constructor(options = {}) {
super(options);
this.config = {
Ajax: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/award_emojis`,
method: 'setData',
loadingTemplate: this.loadingTemplate,
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
/* eslint-enable no-new */
},
},
Filter: {
template: 'name',
},
};
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(({ glEmojiTag }) => { this.glEmojiTag = glEmojiTag; })
.catch(() => { /* ignore error and leave emoji name in the search bar */ });
this.unbindEvents();
this.bindEvents();
}
bindEvents() {
super.bindEvents();
this.listRenderedWrapper = this.listRendered.bind(this);
this.dropdown.addEventListener('render.dl', this.listRenderedWrapper);
}
unbindEvents() {
this.dropdown.removeEventListener('render.dl', this.listRenderedWrapper);
super.unbindEvents();
}
listRendered() {
this.replaceEmojiElement();
}
itemClicked(e) {
super.itemClicked(e, (selected) => {
const name = selected.querySelector('.js-data-value').innerText.trim();
return gl.DropdownUtils.getEscapedText(name);
});
}
renderContent(forceShowList = false) {
this.droplab.changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config);
super.renderContent(forceShowList);
}
replaceEmojiElement() {
if (!this.glEmojiTag) return;
// Replace empty gl-emoji tag to real content
const dropdownItems = [...this.dropdown.querySelectorAll('.filter-dropdown-item')];
dropdownItems.forEach((dropdownItem) => {
const name = dropdownItem.querySelector('.js-data-value').innerText;
const emojiTag = this.glEmojiTag(name);
const emojiElement = dropdownItem.querySelector('gl-emoji');
emojiElement.outerHTML = emojiTag;
});
}
init() {
this.droplab
.addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
window.gl = window.gl || {};
gl.DropdownEmoji = DropdownEmoji;
...@@ -61,7 +61,7 @@ class DropdownHint extends gl.FilteredSearchDropdown { ...@@ -61,7 +61,7 @@ class DropdownHint extends gl.FilteredSearchDropdown {
.map(tokenKey => ({ .map(tokenKey => ({
icon: `fa-${tokenKey.icon}`, icon: `fa-${tokenKey.icon}`,
hint: tokenKey.key, hint: tokenKey.key,
tag: `<${tokenKey.symbol}${tokenKey.key}>`, tag: `<${tokenKey.tag}>`,
type: tokenKey.type, type: tokenKey.type,
})); }));
......
import './dropdown_emoji';
import './dropdown_hint'; import './dropdown_hint';
import './dropdown_non_user'; import './dropdown_non_user';
import './dropdown_user'; import './dropdown_user';
......
...@@ -62,6 +62,11 @@ class FilteredSearchDropdownManager { ...@@ -62,6 +62,11 @@ class FilteredSearchDropdownManager {
}, },
element: this.container.querySelector('#js-dropdown-label'), element: this.container.querySelector('#js-dropdown-label'),
}, },
'my-reaction': {
reference: null,
gl: 'DropdownEmoji',
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
hint: { hint: {
reference: null, reference: null,
gl: 'DropdownHint', gl: 'DropdownHint',
......
...@@ -447,8 +447,13 @@ class FilteredSearchManager { ...@@ -447,8 +447,13 @@ class FilteredSearchManager {
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) { if (match) {
const indexOf = keyParam.indexOf('_'); // Use lastIndexOf because the token key is allowed to contain underscore
const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; // e.g. 'my_reaction' is the token key of 'my_reaction_emoji'
const lastIndexOf = keyParam.lastIndexOf('_');
let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam;
// Replace underscore with hyphen in the sanitizedkey.
// e.g. 'my_reaction' => 'my-reaction'
sanitizedKey = sanitizedKey.replace('_', '-');
const symbol = match.symbol; const symbol = match.symbol;
let quotationsToUse = ''; let quotationsToUse = '';
...@@ -523,7 +528,10 @@ class FilteredSearchManager { ...@@ -523,7 +528,10 @@ class FilteredSearchManager {
const condition = this.filteredSearchTokenKeys const condition = this.filteredSearchTokenKeys
.searchByConditionKeyValue(token.key, token.value.toLowerCase()); .searchByConditionKeyValue(token.key, token.value.toLowerCase());
const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
const keyParam = param ? `${token.key}_${param}` : token.key; // Replace hyphen with underscore to use as request parameter
// e.g. 'my-reaction' => 'my_reaction'
const underscoredKey = token.key.replace('-', '_');
const keyParam = param ? `${underscoredKey}_${param}` : underscoredKey;
let tokenPath = ''; let tokenPath = '';
if (condition) { if (condition) {
......
...@@ -4,26 +4,42 @@ const tokenKeys = [{ ...@@ -4,26 +4,42 @@ const tokenKeys = [{
param: 'username', param: 'username',
symbol: '@', symbol: '@',
icon: 'pencil', icon: 'pencil',
tag: '@author',
}, { }, {
key: 'assignee', key: 'assignee',
type: 'string', type: 'string',
param: 'username', param: 'username',
symbol: '@', symbol: '@',
icon: 'user', icon: 'user',
tag: '@assignee',
}, { }, {
key: 'milestone', key: 'milestone',
type: 'string', type: 'string',
param: 'title', param: 'title',
symbol: '%', symbol: '%',
icon: 'clock-o', icon: 'clock-o',
tag: '%milestone',
}, { }, {
key: 'label', key: 'label',
type: 'array', type: 'array',
param: 'name[]', param: 'name[]',
symbol: '~', symbol: '~',
icon: 'tag', icon: 'tag',
tag: '~label',
}]; }];
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
key: 'my-reaction',
type: 'string',
param: 'emoji',
symbol: '',
icon: 'thumbs-up',
tag: 'emoji',
});
}
const alternativeTokenKeys = [{ const alternativeTokenKeys = [{
key: 'label', key: 'label',
type: 'string', type: 'string',
...@@ -84,6 +100,10 @@ class FilteredSearchTokenKeys { ...@@ -84,6 +100,10 @@ class FilteredSearchTokenKeys {
return tokenKeysWithAlternative.find((tokenKey) => { return tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key; let tokenKeyParam = tokenKey.key;
// Replace hyphen with underscore to compare keyParam with tokenKeyParam
// e.g. 'my-reaction' => 'my_reaction'
tokenKeyParam = tokenKeyParam.replace('-', '_');
if (tokenKey.param) { if (tokenKey.param) {
tokenKeyParam += `_${tokenKey.param}`; tokenKeyParam += `_${tokenKey.param}`;
} }
......
...@@ -6,6 +6,7 @@ const weightTokenKey = { ...@@ -6,6 +6,7 @@ const weightTokenKey = {
param: '', param: '',
symbol: '', symbol: '',
icon: 'balance-scale', icon: 'balance-scale',
tag: 'weight',
}; };
const weightConditions = [{ const weightConditions = [{
...@@ -76,6 +77,10 @@ class FilteredSearchTokenKeysIssuesEE extends gl.FilteredSearchTokenKeys { ...@@ -76,6 +77,10 @@ class FilteredSearchTokenKeysIssuesEE extends gl.FilteredSearchTokenKeys {
return tokenKeysWithAlternative.find((tokenKey) => { return tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key; let tokenKeyParam = tokenKey.key;
// Replace hyphen with underscore to compare keyParam with tokenKeyParam
// e.g. 'my-reaction' => 'my_reaction'
tokenKeyParam = tokenKeyParam.replace('-', '_');
if (tokenKey.param) { if (tokenKey.param) {
tokenKeyParam += `_${tokenKey.param}`; tokenKeyParam += `_${tokenKey.param}`;
} }
......
...@@ -132,6 +132,23 @@ class FilteredSearchVisualTokens { ...@@ -132,6 +132,23 @@ class FilteredSearchVisualTokens {
.catch(() => { }); .catch(() => { });
} }
static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
const container = tokenValueContainer;
const element = tokenValueElement;
return import(/* webpackChunkName: 'emoji' */ '../emoji')
.then((Emoji) => {
if (!Emoji.isEmojiNameValid(tokenValue)) {
return;
}
container.dataset.originalValue = tokenValue;
element.innerHTML = Emoji.glEmojiTag(tokenValue);
})
// ignore error and leave emoji name in the search bar
.catch(() => { });
}
static renderVisualTokenValue(parentElement, tokenName, tokenValue) { static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value'); const tokenValueElement = tokenValueContainer.querySelector('.value');
...@@ -144,6 +161,10 @@ class FilteredSearchVisualTokens { ...@@ -144,6 +161,10 @@ class FilteredSearchVisualTokens {
FilteredSearchVisualTokens.updateUserTokenAppearance( FilteredSearchVisualTokens.updateUserTokenAppearance(
tokenValueContainer, tokenValueElement, tokenValue, tokenValueContainer, tokenValueElement, tokenValue,
); );
} else if (tokenType === 'my-reaction') {
FilteredSearchVisualTokens.updateEmojiTokenAppearance(
tokenValueContainer, tokenValueElement, tokenValue,
);
} }
} }
......
...@@ -225,6 +225,18 @@ ...@@ -225,6 +225,18 @@
color: $common-gray-dark; color: $common-gray-dark;
} }
gl-emoji {
display: inline-block;
font-family: inherit;
font-size: inherit;
vertical-align: inherit;
img {
height: 18px;
width: 18px;
}
}
.form-control { .form-control {
position: relative; position: relative;
min-width: 200px; min-width: 200px;
...@@ -277,7 +289,7 @@ ...@@ -277,7 +289,7 @@
} }
.filtered-search-input-dropdown-menu { .filtered-search-input-dropdown-menu {
max-height: 225px; max-height: 260px;
max-width: 280px; max-width: 280px;
overflow: auto; overflow: auto;
......
class AutocompleteController < ApplicationController class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users] AWARD_EMOJI_MAX = 100
skip_before_action :authenticate_user!, only: [:users, :award_emojis]
before_action :load_project, only: [:users, :project_groups] before_action :load_project, only: [:users, :project_groups]
before_action :find_users, only: [:users] before_action :find_users, only: [:users]
...@@ -52,6 +54,20 @@ class AutocompleteController < ApplicationController ...@@ -52,6 +54,20 @@ class AutocompleteController < ApplicationController
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace) render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
end end
def award_emojis
emoji_with_count = AwardEmoji
.limit(AWARD_EMOJI_MAX)
.where(user: current_user)
.group(:name)
.order(count: :desc, name: :asc)
.count
# Transform from hash to array to guarantee json order
# e.g. { 'thumbsup' => 2, 'thumbsdown' = 1 }
# => [{ name: 'thumbsup' }, { name: 'thumbsdown' }]
render json: emoji_with_count.map { |k, v| { name: k } }
end
private private
def load_users_by_ability def load_users_by_ability
......
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
# sort: string # sort: string
# non_archived: boolean # non_archived: boolean
# iids: integer[] # iids: integer[]
# my_reaction_emoji: string
# #
class IssuableFinder class IssuableFinder
include CreatedAtFilter include CreatedAtFilter
...@@ -51,6 +52,7 @@ class IssuableFinder ...@@ -51,6 +52,7 @@ class IssuableFinder
items = by_iids(items) items = by_iids(items)
items = by_milestone(items) items = by_milestone(items)
items = by_label(items) items = by_label(items)
items = by_my_reaction_emoji(items)
# Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far # Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far
items = by_project(items) items = by_project(items)
...@@ -389,6 +391,14 @@ class IssuableFinder ...@@ -389,6 +391,14 @@ class IssuableFinder
params[:weight] == Issue::WEIGHT_ANY params[:weight] == Issue::WEIGHT_ANY
end end
def by_my_reaction_emoji(items)
if params[:my_reaction_emoji].present? && current_user
items = items.awarded(current_user, params[:my_reaction_emoji])
end
items
end
def by_due_date(items) def by_due_date(items)
if due_date? if due_date?
if filter_by_no_due_date? if filter_by_no_due_date?
......
...@@ -11,6 +11,21 @@ module Awardable ...@@ -11,6 +11,21 @@ module Awardable
end end
module ClassMethods module ClassMethods
def awarded(user, name)
sql = <<~EOL
EXISTS (
SELECT TRUE
FROM award_emoji
WHERE user_id = :user_id AND
name = :name AND
awardable_type = :awardable_type AND
awardable_id = #{self.arel_table.name}.id
)
EOL
where(sql, user_id: user.id, name: name, awardable_type: self.name)
end
def order_upvotes_desc def order_upvotes_desc
order_votes_desc(AwardEmoji::UPVOTE_NAME) order_votes_desc(AwardEmoji::UPVOTE_NAME)
end end
......
...@@ -97,6 +97,13 @@ ...@@ -97,6 +97,13 @@
%span.dropdown-label-box{ style: 'background: {{color}}' } %span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value %span.label-title.js-data-value
{{title}} {{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%gl-emoji
%span.js-data-value.prepend-left-10
{{name}}
- if type == :issues || type == :boards || type == :boards_modal - if type == :issues || type == :boards || type == :boards_modal
#js-dropdown-weight.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-weight.filtered-search-input-dropdown-menu.dropdown-menu
......
...@@ -35,6 +35,7 @@ Rails.application.routes.draw do ...@@ -35,6 +35,7 @@ Rails.application.routes.draw do
get '/autocomplete/users' => 'autocomplete#users' get '/autocomplete/users' => 'autocomplete#users'
get '/autocomplete/users/:id' => 'autocomplete#user' get '/autocomplete/users/:id' => 'autocomplete#user'
get '/autocomplete/projects' => 'autocomplete#projects' get '/autocomplete/projects' => 'autocomplete#projects'
get '/autocomplete/award_emojis' => 'autocomplete#award_emojis'
get '/autocomplete/project_groups' => 'autocomplete#project_groups' get '/autocomplete/project_groups' => 'autocomplete#project_groups'
# Search # Search
......
...@@ -338,5 +338,43 @@ describe AutocompleteController do ...@@ -338,5 +338,43 @@ describe AutocompleteController do
end end
end end
end end
context 'GET award_emojis' do
let(:user2) { create(:user) }
let!(:award_emoji1) { create_list(:award_emoji, 2, user: user, name: 'thumbsup') }
let!(:award_emoji2) { create_list(:award_emoji, 1, user: user, name: 'thumbsdown') }
let!(:award_emoji3) { create_list(:award_emoji, 3, user: user, name: 'star') }
let!(:award_emoji4) { create_list(:award_emoji, 1, user: user, name: 'tea') }
context 'unauthorized user' do
it 'returns empty json' do
get :award_emojis
expect(json_response).to be_empty
end
end
context 'sign in as user without award emoji' do
it 'returns empty json' do
sign_in(user2)
get :award_emojis
expect(json_response).to be_empty
end
end
context 'sign in as user with award emoji' do
it 'returns json sorted by name count' do
sign_in(user)
get :award_emojis
expect(json_response.count).to eq 4
expect(json_response[0]).to match('name' => 'star')
expect(json_response[1]).to match('name' => 'thumbsup')
expect(json_response[2]).to match('name' => 'tea')
expect(json_response[3]).to match('name' => 'thumbsdown')
end
end
end
end end
end end
...@@ -204,6 +204,12 @@ describe 'Dropdown assignee', :js do ...@@ -204,6 +204,12 @@ describe 'Dropdown assignee', :js do
expect(page).to have_css(js_dropdown_assignee, visible: true) expect(page).to have_css(js_dropdown_assignee, visible: true)
end end
it 'opens assignee dropdown with existing my-reaction' do
filtered_search.set('my-reaction:star assignee:')
expect(page).to have_css(js_dropdown_assignee, visible: true)
end
end end
describe 'caching requests' do describe 'caching requests' do
......
require 'rails_helper'
describe 'Dropdown emoji', js: true do
include FilteredSearchHelpers
let!(:project) { create(:project, :public) }
let!(:user) { create(:user, name: 'administrator', username: 'root') }
let!(:issue) { create(:issue, project: project) }
let!(:award_emoji_star) { create(:award_emoji, name: 'star', user: user, awardable: issue) }
let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_emoji) { '#js-dropdown-my-reaction' }
def send_keys_to_filtered_search(input)
input.split("").each do |i|
filtered_search.send_keys(i)
end
sleep 0.5
wait_for_requests
end
def dropdown_emoji_size
page.all('#js-dropdown-my-reaction .filter-dropdown .filter-dropdown-item').size
end
def click_emoji(text)
find('#js-dropdown-my-reaction .filter-dropdown .filter-dropdown-item', text: text).click
end
before do
project.team << [user, :master]
create_list(:award_emoji, 2, user: user, name: 'thumbsup')
create_list(:award_emoji, 1, user: user, name: 'thumbsdown')
create_list(:award_emoji, 3, user: user, name: 'star')
create_list(:award_emoji, 1, user: user, name: 'tea')
end
context 'when user not logged in' do
before do
visit project_issues_path(project)
end
describe 'behavior' do
it 'does not open when the search bar has my-reaction:' do
filtered_search.set('my-reaction:')
expect(page).not_to have_css(js_dropdown_emoji)
end
end
end
context 'when user loggged in' do
before do
sign_in(user)
visit project_issues_path(project)
end
describe 'behavior' do
it 'opens when the search bar has my-reaction:' do
filtered_search.set('my-reaction:')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
it 'closes when the search bar is unfocused' do
find('body').click()
expect(page).to have_css(js_dropdown_emoji, visible: false)
end
it 'should show loading indicator when opened' do
filtered_search.set('my-reaction:')
expect(page).to have_css('#js-dropdown-my-reaction .filter-dropdown-loading', visible: true)
end
it 'should hide loading indicator when loaded' do
send_keys_to_filtered_search('my-reaction:')
expect(page).not_to have_css('#js-dropdown-my-reaction .filter-dropdown-loading')
end
it 'should load all the emojis when opened' do
send_keys_to_filtered_search('my-reaction:')
expect(dropdown_emoji_size).to eq(4)
end
it 'shows the most populated emoji at top of dropdown' do
send_keys_to_filtered_search('my-reaction:')
expect(first('#js-dropdown-my-reaction li')).to have_content(award_emoji_star.name)
end
end
describe 'filtering' do
before do
filtered_search.set('my-reaction')
send_keys_to_filtered_search(':')
end
it 'filters by name' do
send_keys_to_filtered_search('up')
expect(dropdown_emoji_size).to eq(1)
end
it 'filters by case insensitive name' do
send_keys_to_filtered_search('Up')
expect(dropdown_emoji_size).to eq(1)
end
end
describe 'selecting from dropdown' do
before do
filtered_search.set('my-reaction')
send_keys_to_filtered_search(':')
end
it 'fills in the my-reaction name' do
click_emoji('thumbsup')
wait_for_requests
expect(page).to have_css(js_dropdown_emoji, visible: false)
expect_tokens([emoji_token('thumbsup')])
expect_filtered_search_input_empty
end
end
describe 'input has existing content' do
it 'opens my-reaction dropdown with existing search term' do
filtered_search.set('searchTerm my-reaction:')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
it 'opens my-reaction dropdown with existing assignee' do
filtered_search.set('assignee:@user my-reaction:')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
it 'opens my-reaction dropdown with existing label' do
filtered_search.set('label:~bug my-reaction:')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
it 'opens my-reaction dropdown with existing milestone' do
filtered_search.set('milestone:%v1.0 my-reaction:')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
it 'opens my-reaction dropdown with existing my-reaction' do
filtered_search.set('my-reaction:star my-reaction:')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
end
describe 'caching requests' do
it 'caches requests after the first load' do
filtered_search.set('my-reaction')
send_keys_to_filtered_search(':')
initial_size = dropdown_emoji_size
expect(initial_size).to be > 0
create_list(:award_emoji, 1, user: user, name: 'smile')
find('.filtered-search-box .clear-search').click
filtered_search.set('my-reaction')
send_keys_to_filtered_search(':')
expect(dropdown_emoji_size).to eq(initial_size)
end
end
end
end
...@@ -270,6 +270,12 @@ describe 'Dropdown label', js: true do ...@@ -270,6 +270,12 @@ describe 'Dropdown label', js: true do
expect(page).to have_css(js_dropdown_label) expect(page).to have_css(js_dropdown_label)
end end
it 'opens label dropdown with existing my-reaction' do
filtered_search.set('my-reaction:star label:')
expect(page).to have_css(js_dropdown_label)
end
end end
describe 'caching requests' do describe 'caching requests' do
......
...@@ -242,6 +242,12 @@ describe 'Dropdown milestone', :js do ...@@ -242,6 +242,12 @@ describe 'Dropdown milestone', :js do
expect(page).to have_css(js_dropdown_milestone, visible: true) expect(page).to have_css(js_dropdown_milestone, visible: true)
end end
it 'opens milestone dropdown with existing my-reaction' do
filtered_search.set('my-reaction:star milestone:')
expect(page).to have_css(js_dropdown_milestone, visible: true)
end
end end
describe 'caching requests' do describe 'caching requests' do
......
...@@ -100,7 +100,7 @@ describe 'Search bar', js: true do ...@@ -100,7 +100,7 @@ describe 'Search bar', js: true do
find('.filtered-search-box .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.click filtered_search.click
expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5) expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6)
expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset) expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset)
end end
end end
......
...@@ -10,6 +10,9 @@ describe IssuesFinder do ...@@ -10,6 +10,9 @@ describe IssuesFinder do
set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab', created_at: 1.week.ago) } set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab', created_at: 1.week.ago) }
set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') }
set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki', created_at: 1.week.from_now) } set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki', created_at: 1.week.from_now) }
set(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: issue1) }
set(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: issue2) }
set(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: issue3) }
describe '#execute' do describe '#execute' do
set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') }
...@@ -26,6 +29,10 @@ describe IssuesFinder do ...@@ -26,6 +29,10 @@ describe IssuesFinder do
issue1 issue1
issue2 issue2
issue3 issue3
award_emoji1
award_emoji2
award_emoji3
end end
context 'scope: all' do context 'scope: all' do
...@@ -296,6 +303,34 @@ describe IssuesFinder do ...@@ -296,6 +303,34 @@ describe IssuesFinder do
end end
end end
context 'filtering by reaction name' do
context 'user searches by "thumbsup" reaction' do
let(:params) { { my_reaction_emoji: 'thumbsup' } }
it 'returns issues that the user thumbsup to' do
expect(issues).to contain_exactly(issue1)
end
end
context 'user2 searches by "thumbsup" reaction' do
let(:search_user) { user2 }
let(:params) { { my_reaction_emoji: 'thumbsup' } }
it 'returns issues that the user2 thumbsup to' do
expect(issues).to contain_exactly(issue2)
end
end
context 'user searches by "thumbsdown" reaction' do
let(:params) { { my_reaction_emoji: 'thumbsdown' } }
it 'returns issues that the user thumbsdown to' do
expect(issues).to contain_exactly(issue3)
end
end
end
context 'when the user is unauthorized' do context 'when the user is unauthorized' do
let(:search_user) { nil } let(:search_user) { nil }
......
...@@ -351,14 +351,17 @@ describe('DropDown', function () { ...@@ -351,14 +351,17 @@ describe('DropDown', function () {
describe('render', function () { describe('render', function () {
beforeEach(function () { beforeEach(function () {
this.list = { querySelector: () => {} }; this.list = { querySelector: () => {}, dispatchEvent: () => {} };
this.dropdown = { renderChildren: () => {}, list: this.list }; this.dropdown = { renderChildren: () => {}, list: this.list };
this.renderableList = {}; this.renderableList = {};
this.data = [0, 1]; this.data = [0, 1];
this.customEvent = {};
spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); spyOn(this.dropdown, 'renderChildren').and.callFake(data => data);
spyOn(this.list, 'querySelector').and.returnValue(this.renderableList); spyOn(this.list, 'querySelector').and.returnValue(this.renderableList);
spyOn(this.list, 'dispatchEvent');
spyOn(this.data, 'map').and.callThrough(); spyOn(this.data, 'map').and.callThrough();
spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
DropDown.prototype.render.call(this.dropdown, this.data); DropDown.prototype.render.call(this.dropdown, this.data);
}); });
...@@ -375,6 +378,14 @@ describe('DropDown', function () { ...@@ -375,6 +378,14 @@ describe('DropDown', function () {
expect(this.renderableList.innerHTML).toBe('01'); expect(this.renderableList.innerHTML).toBe('01');
}); });
it('should call render.dl', function () {
expect(window.CustomEvent).toHaveBeenCalledWith('render.dl', jasmine.any(Object));
});
it('should call dispatchEvent with the customEvent', function () {
expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent);
});
describe('if no data argument is passed', function () { describe('if no data argument is passed', function () {
beforeEach(function () { beforeEach(function () {
this.data.map.calls.reset(); this.data.map.calls.reset();
...@@ -394,7 +405,7 @@ describe('DropDown', function () { ...@@ -394,7 +405,7 @@ describe('DropDown', function () {
describe('if no dynamic list is present', function () { describe('if no dynamic list is present', function () {
beforeEach(function () { beforeEach(function () {
this.list = { querySelector: () => {} }; this.list = { querySelector: () => {}, dispatchEvent: () => {} };
this.dropdown = { renderChildren: () => {}, list: this.list }; this.dropdown = { renderChildren: () => {}, list: this.list };
this.data = [0, 1]; this.data = [0, 1];
......
...@@ -8,6 +8,7 @@ import '~/filtered_search/filtered_search_token_keys_issues_ee'; ...@@ -8,6 +8,7 @@ import '~/filtered_search/filtered_search_token_keys_issues_ee';
param: '', param: '',
symbol: '', symbol: '',
icon: 'balance-scale', icon: 'balance-scale',
tag: 'weight',
}; };
describe('get', () => { describe('get', () => {
......
...@@ -12,17 +12,25 @@ describe Awardable do ...@@ -12,17 +12,25 @@ describe Awardable do
describe "ClassMethods" do describe "ClassMethods" do
let!(:issue2) { create(:issue) } let!(:issue2) { create(:issue) }
let!(:award_emoji2) { create(:award_emoji, awardable: issue2) }
before do describe "orders" do
create(:award_emoji, awardable: issue2) it "orders on upvotes" do
end expect(Issue.order_upvotes_desc.to_a).to eq [issue2, issue]
end
it "orders on upvotes" do it "orders on downvotes" do
expect(Issue.order_upvotes_desc.to_a).to eq [issue2, issue] expect(Issue.order_downvotes_desc.to_a).to eq [issue, issue2]
end
end end
it "orders on downvotes" do describe ".awarded" do
expect(Issue.order_downvotes_desc.to_a).to eq [issue, issue2] it "filters by user and emoji name" do
expect(Issue.awarded(award_emoji.user, "thumbsup")).to be_empty
expect(Issue.awarded(award_emoji.user, "thumbsdown")).to eq [issue]
expect(Issue.awarded(award_emoji2.user, "thumbsup")).to eq [issue2]
expect(Issue.awarded(award_emoji2.user, "thumbsdown")).to be_empty
end
end end
end end
......
...@@ -58,11 +58,17 @@ module FilteredSearchHelpers ...@@ -58,11 +58,17 @@ module FilteredSearchHelpers
page.all(:css, '.tokens-container li .selectable').each_with_index do |el, index| page.all(:css, '.tokens-container li .selectable').each_with_index do |el, index|
token_name = tokens[index][:name] token_name = tokens[index][:name]
token_value = tokens[index][:value] token_value = tokens[index][:value]
token_emoji = tokens[index][:emoji_name]
expect(el.find('.name')).to have_content(token_name) expect(el.find('.name')).to have_content(token_name)
if token_value if token_value
expect(el.find('.value')).to have_content(token_value) expect(el.find('.value')).to have_content(token_value)
end end
# gl-emoji content is blank when the emoji unicode is not supported
if token_emoji
selector = %(gl-emoji[data-name="#{token_emoji}"])
expect(el.find('.value')).to have_css(selector)
end
end end
end end
end end
...@@ -89,6 +95,10 @@ module FilteredSearchHelpers ...@@ -89,6 +95,10 @@ module FilteredSearchHelpers
create_token('Label', label_name, symbol) create_token('Label', label_name, symbol)
end end
def emoji_token(emoji_name = nil)
{ name: 'My-Reaction', emoji_name: emoji_name }
end
def default_placeholder def default_placeholder
'Search or filter results...' 'Search or filter results...'
end end
......
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