Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
heptapod
heptapod
Commits
0da88f148a1e
Commit
7187395e
authored
Aug 30, 2017
by
Hiroyuki Sato
Committed by
Phil Hughes
Aug 30, 2017
Browse files
Add filter by my reaction
parent
4e3e8fdc1a9b
Changes
26
Hide whitespace changes
Inline
Side-by-side
app/assets/javascripts/droplab/drop_down.js
View file @
0da88f14
...
...
@@ -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
)
{
...
...
app/assets/javascripts/filtered_search/dropdown_emoji.js
0 → 100644
View file @
0da88f14
/* 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
;
app/assets/javascripts/filtered_search/dropdown_hint.js
View file @
0da88f14
...
...
@@ -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
,
}));
...
...
app/assets/javascripts/filtered_search/filtered_search_bundle.js
View file @
0da88f14
import
'
./dropdown_emoji
'
;
import
'
./dropdown_hint
'
;
import
'
./dropdown_non_user
'
;
import
'
./dropdown_user
'
;
...
...
app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
View file @
0da88f14
...
...
@@ -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
'
,
...
...
app/assets/javascripts/filtered_search/filtered_search_manager.js
View file @
0da88f14
...
...
@@ -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
)
{
...
...
app/assets/javascripts/filtered_search/filtered_search_token_keys.js
View file @
0da88f14
...
...
@@ -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
}
`
;
}
...
...
app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
View file @
0da88f14
...
...
@@ -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
,
);
}
}
...
...
app/assets/stylesheets/framework/filters.scss
View file @
0da88f14
...
...
@@ -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
:
2
25
px
;
max-height
:
2
60
px
;
max-width
:
280px
;
overflow
:
auto
;
...
...
app/controllers/autocomplete_controller.rb
View file @
0da88f14
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 @@ def projects
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
...
...
app/finders/issuable_finder.rb
View file @
0da88f14
...
...
@@ -18,6 +18,7 @@
# sort: string
# non_archived: boolean
# iids: integer[]
# my_reaction_emoji: string
#
class
IssuableFinder
include
CreatedAtFilter
...
...
@@ -46,6 +47,7 @@ def execute
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 @@ def by_label(items)
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?
...
...
app/models/concerns/awardable.rb
View file @
0da88f14
...
...
@@ -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
...
...
app/views/shared/issuable/_search_bar.html.haml
View file @
0da88f14
...
...
@@ -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
...
...
changelogs/unreleased/add-filter-by-my-reaction.yml
0 → 100644
View file @
0da88f14
---
title
:
Add my reaction filter to search bar
merge_request
:
12962
author
:
Hiroyuki Sato
config/routes.rb
View file @
0da88f14
...
...
@@ -27,6 +27,7 @@
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'
...
...
spec/controllers/autocomplete_controller_spec.rb
View file @
0da88f14
...
...
@@ -339,4 +339,42 @@
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
spec/features/issues/filtered_search/dropdown_assignee_spec.rb
View file @
0da88f14
...
...
@@ -204,6 +204,12 @@ def click_assignee(text)
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
...
...
spec/features/issues/filtered_search/dropdown_emoji_spec.rb
0 → 100644
View file @
0da88f14
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