Commit 7187395e authored by Hiroyuki Sato's avatar Hiroyuki Sato Committed by Phil Hughes

Add filter by my reaction

parent df8ca5aa
......@@ -85,6 +85,13 @@ class DropDown {
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join('');
const listEvent = new CustomEvent('render.dl', {
detail: {
list: this,
},
});
this.list.dispatchEvent(listEvent);
}
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 {
.map(tokenKey => ({
icon: `fa-${tokenKey.icon}`,
hint: tokenKey.key,
tag: `<${tokenKey.symbol}${tokenKey.key}>`,
tag: `<${tokenKey.tag}>`,
type: tokenKey.type,
}));
......
import './dropdown_emoji';
import './dropdown_hint';
import './dropdown_non_user';
import './dropdown_user';
......
......@@ -58,6 +58,11 @@ class FilteredSearchDropdownManager {
},
element: this.container.querySelector('#js-dropdown-label'),
},
'my-reaction': {
reference: null,
gl: 'DropdownEmoji',
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
hint: {
reference: null,
gl: 'DropdownHint',
......
......@@ -439,8 +439,13 @@ class FilteredSearchManager {
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) {
const indexOf = keyParam.indexOf('_');
const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam;
// Use lastIndexOf because the token key is allowed to contain underscore
// 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;
let quotationsToUse = '';
......@@ -515,7 +520,10 @@ class FilteredSearchManager {
const condition = this.filteredSearchTokenKeys
.searchByConditionKeyValue(token.key, token.value.toLowerCase());
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 = '';
if (condition) {
......
......@@ -4,26 +4,42 @@ const tokenKeys = [{
param: 'username',
symbol: '@',
icon: 'pencil',
tag: '@author',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
icon: 'user',
tag: '@assignee',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
icon: 'clock-o',
tag: '%milestone',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
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 = [{
key: 'label',
type: 'string',
......@@ -84,6 +100,10 @@ class FilteredSearchTokenKeys {
return tokenKeysWithAlternative.find((tokenKey) => {
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) {
tokenKeyParam += `_${tokenKey.param}`;
}
......
......@@ -132,6 +132,23 @@ class FilteredSearchVisualTokens {
.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) {
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
......@@ -144,6 +161,10 @@ class FilteredSearchVisualTokens {
FilteredSearchVisualTokens.updateUserTokenAppearance(
tokenValueContainer, tokenValueElement, tokenValue,
);
} else if (tokenType === 'my-reaction') {
FilteredSearchVisualTokens.updateEmojiTokenAppearance(
tokenValueContainer, tokenValueElement, tokenValue,
);
}
}
......
......@@ -225,6 +225,18 @@
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 {
position: relative;
min-width: 200px;
......@@ -277,7 +289,7 @@
}
.filtered-search-input-dropdown-menu {
max-height: 225px;
max-height: 260px;
max-width: 280px;
overflow: auto;
......
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]
before_action :find_users, only: [:users]
......@@ -48,6 +50,20 @@ class AutocompleteController < ApplicationController
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
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
def find_users
......
......@@ -18,6 +18,7 @@
# sort: string
# non_archived: boolean
# iids: integer[]
# my_reaction_emoji: string
#
class IssuableFinder
include CreatedAtFilter
......@@ -46,6 +47,7 @@ class IssuableFinder
items = by_iids(items)
items = by_milestone(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
items = by_project(items)
......@@ -371,6 +373,14 @@ class IssuableFinder
items
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)
if due_date?
if filter_by_no_due_date?
......
......@@ -11,6 +11,21 @@ module Awardable
end
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
order_votes_desc(AwardEmoji::UPVOTE_NAME)
end
......
......@@ -93,6 +93,13 @@
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{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}}
%button.clear-search.hidden{ type: 'button' }
= icon('times')
.filter-dropdown-container
......
---
title: Add my reaction filter to search bar
merge_request: 12962
author: Hiroyuki Sato
......@@ -27,6 +27,7 @@ Rails.application.routes.draw do
get '/autocomplete/users' => 'autocomplete#users'
get '/autocomplete/users/:id' => 'autocomplete#user'
get '/autocomplete/projects' => 'autocomplete#projects'
get '/autocomplete/award_emojis' => 'autocomplete#award_emojis'
# Search
get 'search' => 'search#show'
......
......@@ -339,4 +339,42 @@ describe AutocompleteController do
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
......@@ -204,6 +204,12 @@ describe 'Dropdown assignee', :js do
expect(page).to have_css(js_dropdown_assignee, visible: true)
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
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
expect(page).to have_css(js_dropdown_label)
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
describe 'caching requests' do
......
......@@ -242,6 +242,12 @@ describe 'Dropdown milestone', :js do
expect(page).to have_css(js_dropdown_milestone, visible: true)
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
describe 'caching requests' do
......
......@@ -100,7 +100,7 @@ describe 'Search bar', js: true do
find('.filtered-search-box .clear-search').click
filtered_search.click
expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 4)
expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5)
expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset)
end
end
......
......@@ -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(: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(: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
set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') }
......@@ -26,6 +29,10 @@ describe IssuesFinder do
issue1
issue2
issue3
award_emoji1
award_emoji2
award_emoji3
end
context 'scope: all' do
......@@ -250,6 +257,34 @@ describe IssuesFinder do
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
let(:search_user) { nil }
......
......@@ -351,14 +351,17 @@ describe('DropDown', function () {
describe('render', function () {
beforeEach(function () {
this.list = { querySelector: () => {} };
this.list = { querySelector: () => {}, dispatchEvent: () => {} };
this.dropdown = { renderChildren: () => {}, list: this.list };
this.renderableList = {};
this.data = [0, 1];
this.customEvent = {};
spyOn(this.dropdown, 'renderChildren').and.callFake(data => data);
spyOn(this.list, 'querySelector').and.returnValue(this.renderableList);
spyOn(this.list, 'dispatchEvent');
spyOn(this.data, 'map').and.callThrough();
spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
DropDown.prototype.render.call(this.dropdown, this.data);
});
......@@ -375,6 +378,14 @@ describe('DropDown', function () {
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 () {
beforeEach(function () {
this.data.map.calls.reset();
......@@ -394,7 +405,7 @@ describe('DropDown', function () {
describe('if no dynamic list is present', function () {
beforeEach(function () {
this.list = { querySelector: () => {} };
this.list = { querySelector: () => {}, dispatchEvent: () => {} };
this.dropdown = { renderChildren: () => {}, list: this.list };
this.data = [0, 1];
......
......@@ -12,17 +12,25 @@ describe Awardable do
describe "ClassMethods" do
let!(:issue2) { create(:issue) }
let!(:award_emoji2) { create(:award_emoji, awardable: issue2) }
before do
create(:award_emoji, awardable: issue2)
end
describe "orders" do
it "orders on upvotes" do
expect(Issue.order_upvotes_desc.to_a).to eq [issue2, issue]
end
it "orders on upvotes" do
expect(Issue.order_upvotes_desc.to_a).to eq [issue2, issue]
it "orders on downvotes" do
expect(Issue.order_downvotes_desc.to_a).to eq [issue, issue2]
end
end
it "orders on downvotes" do
expect(Issue.order_downvotes_desc.to_a).to eq [issue, issue2]
describe ".awarded" do
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
......
......@@ -58,11 +58,17 @@ module FilteredSearchHelpers
page.all(:css, '.tokens-container li .selectable').each_with_index do |el, index|
token_name = tokens[index][:name]
token_value = tokens[index][:value]
token_emoji = tokens[index][:emoji_name]
expect(el.find('.name')).to have_content(token_name)
if token_value
expect(el.find('.value')).to have_content(token_value)
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
......@@ -89,6 +95,10 @@ module FilteredSearchHelpers
create_token('Label', label_name, symbol)
end
def emoji_token(emoji_name = nil)
{ name: 'My-Reaction', emoji_name: emoji_name }
end
def default_placeholder
'Search or filter results...'
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