Project

General

Profile

Feature #42630 » Add-reaction-feature.patch

Includes a fix for the position of reaction buttons when Gravatar icons are enabled - Katsuya HIDAKA, 2025-05-08 09:45

View differences:

app/assets/images/icons.svg
459 459
      <path d="M19 15v6h3"/>
460 460
      <path d="M11 21v-6l2.5 3l2.5 -3v6"/>
461 461
    </symbol>
462
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--thumb-up">
463
      <path d="M7 11v8a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-7a1 1 0 0 1 1 -1h3a4 4 0 0 0 4 -4v-1a2 2 0 0 1 4 0v5h3a2 2 0 0 1 2 2l-1 5a2 3 0 0 1 -2 2h-7a3 3 0 0 1 -3 -3"/>
464
    </symbol>
465
    <symbol viewBox="0 0 24 24" id="icon--thumb-up-filled">
466
      <path d="M13 3a3 3 0 0 1 2.995 2.824l.005 .176v4h2a3 3 0 0 1 2.98 2.65l.015 .174l.005 .176l-.02 .196l-1.006 5.032c-.381 1.626 -1.502 2.796 -2.81 2.78l-.164 -.008h-8a1 1 0 0 1 -.993 -.883l-.007 -.117l.001 -9.536a1 1 0 0 1 .5 -.865a2.998 2.998 0 0 0 1.492 -2.397l.007 -.202v-1a3 3 0 0 1 3 -3z"/>
467
      <path d="M5 10a1 1 0 0 1 .993 .883l.007 .117v9a1 1 0 0 1 -.883 .993l-.117 .007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-7a2 2 0 0 1 1.85 -1.995l.15 -.005h1z"/>
468
    </symbol>
462 469
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--time">
463 470
      <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
464 471
      <path d="M12 7v5l3 3"/>
app/assets/javascripts/application-legacy.js
1222 1222
  });
1223 1223
}
1224 1224

  
1225
function setupHoverTooltips() {
1226
  $("[title]:not(.no-tooltip)").tooltip({
1225
function setupHoverTooltips(container) {
1226
  $(container || 'body').find("[title]:not(.no-tooltip)").tooltip({
1227 1227
    show: {
1228 1228
      delay: 400
1229 1229
    },
......
1233 1233
    }
1234 1234
  });
1235 1235
}
1236

  
1236
function removeHoverTooltips(container) {
1237
  $(container || 'body').find("[title]:not(.no-tooltip)").tooltip('destroy')
1238
}
1237 1239
$(function() { setupHoverTooltips(); });
1238 1240

  
1239 1241
function inlineAutoComplete(element) {
app/assets/stylesheets/application.css
2083 2083

  
2084 2084
img.filecontent.image {background-image: url(/transparent.png);}
2085 2085

  
2086
/* Reaction styles */
2087
.reaction-button.reacted .icon-svg {
2088
  fill: #126fa7;
2089
  stroke: none;
2090
}
2091
.reaction-button.reacted:hover .icon-svg {
2092
  fill: #c61a1a;
2093
}
2094
.reaction-button .icon-label {
2095
  margin-left: 3px;
2096
  margin-bottom: -1px;
2097
}
2098
.reaction-button.readonly {
2099
  cursor: default;
2100
}
2101
.reaction-button.readonly .icon-svg {
2102
  stroke: #999;
2103
}
2104
.reaction-button.readonly .icon-label {
2105
  color: #999;
2106
}
2107
div.issue.details .reaction {
2108
  float: right;
2109
  font-size: 0.9em;
2110
  margin-top: 0.5em;
2111
  margin-left: 10px;
2112
  clear: right;
2113
}
2114
div.message .reaction {
2115
  float: right;
2116
  font-size: 0.9em;
2117
  margin-left: 10px;
2118
}
2119
div.news .reaction {
2120
  float: right;
2121
  font-size: 0.9em;
2122
  margin-left: 10px;
2123
}
2124

  
2086 2125
/* Custom JQuery styles */
2087 2126
.ui-autocomplete, .ui-menu {
2088 2127
  border-radius: 2px;
app/controllers/messages_controller.rb
51 51
      offset(@reply_pages.offset).
52 52
      to_a
53 53

  
54
    Message.preload_reaction_details(@replies)
55

  
54 56
    @reply = Message.new(:subject => "RE: #{@message.subject}")
55 57
    render :action => "show", :layout => false if request.xhr?
56 58
  end
app/controllers/news_controller.rb
67 67
  end
68 68

  
69 69
  def show
70
    @comments = @news.comments.to_a
70
    @comments = @news.comments.preload(:commented).to_a
71 71
    @comments.reverse! if User.current.wants_comments_in_reverse_order?
72

  
73
    Comment.preload_reaction_details(@comments)
72 74
  end
73 75

  
74 76
  def new
app/controllers/reactions_controller.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class ReactionsController < ApplicationController
21
  before_action :require_login
22

  
23
  before_action :check_enabled
24
  before_action :set_object, :authorize_reactable
25

  
26
  def create
27
    respond_to do |format|
28
      format.js do
29
        @object.reactions.find_or_create_by!(user: User.current)
30
      end
31
      format.any { head :not_found }
32
    end
33
  end
34

  
35
  def destroy
36
    respond_to do |format|
37
      format.js do
38
        reaction = @object.reactions.by(User.current).find_by(id: params[:id])
39
        reaction&.destroy
40
      end
41
      format.any { head :not_found }
42
    end
43
  end
44

  
45
  private
46

  
47
  def check_enabled
48
    render_403 unless Setting.reactions_enabled?
49
  end
50

  
51
  def set_object
52
    object_type = params[:object_type]
53

  
54
    unless Redmine::Reaction::REACTABLE_TYPES.include?(object_type)
55
      render_403
56
      return
57
    end
58

  
59
    @object = object_type.constantize.find(params[:object_id])
60
  end
61

  
62
  def authorize_reactable
63
    render_403 unless Redmine::Reaction.writable?(@object, User.current)
64
  end
65
end
app/helpers/issues_helper.rb
22 22
  include Redmine::Export::PDF::IssuesPdfHelper
23 23
  include IssueStatusesHelper
24 24
  include QueriesHelper
25
  include ReactionsHelper
25 26

  
26 27
  def issue_list(issues, &)
27 28
    ancestors = []
app/helpers/journals_helper.rb
19 19

  
20 20
module JournalsHelper
21 21
  include Redmine::QuoteReply::Helper
22
  include ReactionsHelper
22 23

  
23 24
  # Returns the attachments of a journal that are displayed as thumbnails
24 25
  def journal_thumbnail_attachments(journal)
......
41 42
    end
42 43

  
43 44
    if journal.notes.present?
45
      links << reaction_button(journal)
46

  
44 47
      if options[:reply_links]
45 48
        url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
46 49
        links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
app/helpers/messages_helper.rb
19 19

  
20 20
module MessagesHelper
21 21
  include Redmine::QuoteReply::Helper
22
  include ReactionsHelper
22 23
end
app/helpers/news_helper.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20 20
module NewsHelper
21
  include ReactionsHelper
21 22
end
app/helpers/reactions_helper.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module ReactionsHelper
21
  # Maximum number of users to display in the reaction button tooltip
22
  DISPLAY_REACTION_USERS_LIMIT = 10
23

  
24
  def reaction_button(object)
25
    return unless Redmine::Reaction.visible?(object, User.current)
26

  
27
    detail = object.reaction_detail
28

  
29
    reaction = detail.user_reaction
30
    count = detail.reaction_count
31
    visible_user_names = detail.visible_users.take(DISPLAY_REACTION_USERS_LIMIT).map(&:name)
32

  
33
    tooltip = build_reaction_tooltip(visible_user_names, count)
34

  
35
    if Redmine::Reaction.writable?(object, User.current)
36
      if reaction&.persisted?
37
        reaction_button_reacted(object, reaction, count, tooltip)
38
      else
39
        reaction_button_not_reacted(object, count, tooltip)
40
      end
41
    else
42
      reaction_button_readonly(object, count, tooltip)
43
    end
44
  end
45

  
46
  def reaction_id_for(object)
47
    dom_id(object, :reaction)
48
  end
49

  
50
  private
51

  
52
  def reaction_button_reacted(object, reaction, count, tooltip)
53
    reaction_button_wrapper object do
54
      link_to(
55
        sprite_icon('thumb-up-filled', count),
56
        reaction_path(reaction, object_type: object.class.name, object_id: object),
57
        remote: true, method: :delete,
58
        class: ['icon', 'reaction-button', 'reacted'],
59
        title: tooltip
60
      )
61
    end
62
  end
63

  
64
  def reaction_button_not_reacted(object, count, tooltip)
65
    reaction_button_wrapper object do
66
      link_to(
67
        sprite_icon('thumb-up', count),
68
        reactions_path(object_type: object.class.name, object_id: object),
69
        remote: true, method: :post,
70
        class: 'icon reaction-button',
71
        title: tooltip
72
      )
73
    end
74
  end
75

  
76
  def reaction_button_readonly(object, count, tooltip)
77
    reaction_button_wrapper object do
78
      tag.span(class: 'icon reaction-button readonly', title: tooltip) do
79
        sprite_icon('thumb-up', count)
80
      end
81
    end
82
  end
83

  
84
  def reaction_button_wrapper(object, &)
85
    tag.span(data: { 'reaction-button-id': reaction_id_for(object) }, &)
86
  end
87

  
88
  def build_reaction_tooltip(visible_user_names, count)
89
    return if count.zero?
90

  
91
    display_user_names = visible_user_names.dup
92
    others = count - visible_user_names.size
93

  
94
    if others.positive?
95
      display_user_names << I18n.t(:reaction_text_x_other_users, count: others)
96
    end
97

  
98
    display_user_names.to_sentence(locale: I18n.locale)
99
  end
100
end
app/models/comment.rb
19 19

  
20 20
class Comment < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
23

  
22 24
  belongs_to :commented, :polymorphic => true, :counter_cache => true
23 25
  belongs_to :author, :class_name => 'User'
24 26

  
......
28 30

  
29 31
  safe_attributes 'comments'
30 32

  
33
  delegate :visible?, to: :commented
34

  
31 35
  def comments=(arg)
32 36
    self.content = arg
33 37
  end
......
36 40
    content
37 41
  end
38 42

  
43
  def project
44
    commented.respond_to?(:project) ? commented.project : nil
45
  end
46

  
39 47
  private
40 48

  
41 49
  def send_notification
app/models/issue.rb
25 25
  before_validation :clear_disabled_fields
26 26
  before_save :set_parent_id
27 27
  include Redmine::NestedSet::IssueNestedSet
28
  include Redmine::Reaction::Reactable
28 29

  
29 30
  belongs_to :project
30 31
  belongs_to :tracker
......
916 917
    result = journals.
917 918
      preload(:details).
918 919
      preload(:user => :email_address).
919
      reorder(:created_on, :id).to_a
920
      reorder(:created_on, :id).
921
      to_a
920 922

  
921 923
    result.each_with_index {|j, i| j.indice = i + 1}
922 924

  
......
927 929
    end
928 930
    Journal.preload_journals_details_custom_fields(result)
929 931
    result.select! {|journal| journal.notes? || journal.visible_details.any?}
932

  
933
    Journal.preload_reaction_details(result)
934

  
930 935
    result
931 936
  end
932 937

  
app/models/journal.rb
19 19

  
20 20
class Journal < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
22 23

  
23 24
  belongs_to :journalized, :polymorphic => true
24 25
  # added as a quick fix to allow eager loading of the polymorphic association
app/models/message.rb
19 19

  
20 20
class Message < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
23

  
22 24
  belongs_to :board
23 25
  belongs_to :author, :class_name => 'User'
24 26
  acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
app/models/news.rb
19 19

  
20 20
class News < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
23

  
22 24
  belongs_to :project
23 25
  belongs_to :author, :class_name => 'User'
24 26
  has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all
app/models/reaction.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class Reaction < ApplicationRecord
21
  belongs_to :reactable, polymorphic: true
22
  belongs_to :user
23

  
24
  validates :reactable_type, inclusion: { in: Redmine::Reaction::REACTABLE_TYPES }
25

  
26
  scope :by, ->(user) { where(user: user) }
27
  scope :for_reactable, ->(reactable) { where(reactable: reactable) }
28

  
29
  # Represents reaction details for a reactable object
30
  Detail = Struct.new(
31
    # Total number of reactions
32
    :reaction_count,
33
    # Users who reacted and are visible to the target user
34
    :visible_users,
35
    # Reaction of the target user
36
    :user_reaction
37
  ) do
38
    def initialize(reaction_count: 0, visible_users: [], user_reaction: nil)
39
      super
40
    end
41
  end
42

  
43
  def self.build_detail_map_for(reactables, user)
44
    reactions = preload(:user)
45
                  .for_reactable(reactables)
46
                  .select(:id, :reactable_id, :user_id)
47
                  .order(id: :desc)
48

  
49
    # Prepare IDs of users who reacted and are visible to the user
50
    visible_user_ids = User.visible(user)
51
                         .joins(:reactions)
52
                         .where(reactions: for_reactable(reactables))
53
                         .pluck(:id).to_set
54

  
55
    reactions.each_with_object({}) do |reaction, m|
56
      m[reaction.reactable_id] ||= Detail.new
57

  
58
      m[reaction.reactable_id].then do |detail|
59
        detail.reaction_count += 1
60
        detail.visible_users << reaction.user if visible_user_ids.include?(reaction.user.id)
61
        detail.user_reaction = reaction if reaction.user == user
62
      end
63
    end
64
  end
65
end
app/models/user.rb
92 92
  has_one :atom_token, lambda {where "#{table.name}.action='feeds'"}, :class_name => 'Token'
93 93
  has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token'
94 94
  has_many :email_addresses, :dependent => :delete_all
95
  has_many :reactions, dependent: :delete_all
95 96
  belongs_to :auth_source
96 97

  
97 98
  scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")}
app/views/issues/show.html.erb
39 39

  
40 40
<div class="subject">
41 41
<%= render_issue_subject_with_tree(@issue) %>
42
</div>
43

  
44
<div class="reaction">
45
  <%= reaction_button @issue %>
42 46
</div>
43 47
        <p class="author">
44 48
        <%= authoring @issue.created_on, @issue.author %>.
app/views/messages/show.html.erb
27 27
<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
28 28

  
29 29
<div class="message">
30
<div class="reaction">
31
  <%= reaction_button @topic %>
32
</div>
30 33
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
31 34
<div id="message_topic_wiki" class="wiki">
32 35
<%= textilizable(@topic, :content) %>
......
44 47
<% @replies.each do |message| %>
45 48
  <div class="message reply" id="<%= "message-#{message.id}" %>">
46 49
    <div class="contextual">
50
      <%= reaction_button message %>
47 51
      <%= quote_reply(
48 52
            url_for(:action => 'quote', :id => message, :format => 'js'),
49 53
            "#message-#{message.id} .wiki",
app/views/news/show.html.erb
22 22
</div>
23 23
<% end %>
24 24

  
25
<p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
26
<span class="author"><%= authoring @news.created_on, @news.author %></span></p>
27
<div class="wiki">
28
<%= textilizable(@news, :description) %>
25
<div class="news">
26
  <div class="reaction">
27
    <%= reaction_button @news %>
28
  </div>
29
  <p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
30
  <span class="author"><%= authoring @news.created_on, @news.author %></span></p>
31
  <div class="wiki">
32
  <%= textilizable(@news, :description) %>
33
  </div>
34
  <%= link_to_attachments @news %>
29 35
</div>
30
<%= link_to_attachments @news %>
31 36
<br />
32 37

  
33 38
<div id="comments" style="margin-bottom:16px;">
......
38 43
<% @comments.each do |comment| %>
39 44
    <% next if comment.new_record? %>
40 45
    <div class="contextual">
46
    <%= reaction_button comment %>
41 47
    <%= link_to_if_authorized sprite_icon('del', l(:button_delete)), { :controller => 'comments', :action => 'destroy', :id => @news, :comment_id => comment},
42 48
                              :data => {:confirm => l(:text_are_you_sure)}, :method => :delete,
43 49
                              :title => l(:button_delete),
app/views/reactions/_replace_button.js.erb
1
(() => {
2
  const button = $('[data-reaction-button-id=<%= reaction_id_for @object %>]');
3

  
4
  removeHoverTooltips(button);
5
  button.html($('<%=j reaction_button @object %>').children());
6
  setupHoverTooltips(button);
7
})();
app/views/reactions/create.js.erb
1
<%= render 'replace_button' %>
app/views/reactions/destroy.js.erb
1
<%= render 'replace_button' %>
app/views/settings/_general.html.erb
37 37

  
38 38
<p><%= setting_text_field :feeds_limit, :size => 6 %></p>
39 39

  
40
<p><%= setting_check_box :reactions_enabled %></p>
41

  
40 42
<%= call_hook(:view_settings_general_form) %>
41 43
</div>
42 44

  
config/icon_source.yml
221 221
- name: unwatch
222 222
  svg: eye-off
223 223
- name: copy-pre-content
224
  svg: clipboard
224
  svg: clipboard
225
- name: thumb-up
226
  svg: thumb-up
227
- name: thumb-up-filled
228
  svg: thumb-up
229
  style: filled
config/locales/en.yml
528 528
  setting_twofa: Two-factor authentication
529 529
  setting_related_issues_default_columns: Related and sub issues list defaults
530 530
  setting_display_related_issues_table_headers: Show table headers
531
  setting_reactions_enabled: Enable reactions
531 532

  
532 533
  permission_add_project: Create project
533 534
  permission_add_subprojects: Create subprojects
......
1432 1433
  text_project_destroy_enter_identifier: "To confirm, please enter the project's identifier (%{identifier}) below."
1433 1434
  field_name_or_email_or_login: Name, email or login
1434 1435
  setting_wiki_tablesort_enabled: Javascript based table sorting in wiki content
1436
  reaction_text_x_other_users:
1437
    one: "1 other"
1438
    other: "%{count} others"
config/locales/ja.yml
1457 1457
  setting_related_issues_default_columns: 関連するチケットと子チケットの一覧で表示する項目
1458 1458
  setting_display_related_issues_table_headers: テーブルヘッダを表示
1459 1459
  error_can_not_remove_role_reason_members_html: "<p>以下のプロジェクトにこのロールのメンバーがいます:<br>%{projects}</p>"
1460
  setting_reactions_enabled: リアクション機能を有効にする
1461
  reaction_text_x_other_users:
1462
    one: 他1人
1463
    other: "他%{count}人"
config/routes.rb
61 61
    end
62 62
  end
63 63

  
64
  resources :reactions, only: [:create, :destroy]
65

  
64 66
  get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt'
65 67
  get '/issues/gantt', :to => 'gantts#show'
66 68

  
config/settings.yml
363 363
  default: 1
364 364
wiki_tablesort_enabled:
365 365
  default: 1
366
reactions_enabled:
367
  default: 1
db/migrate/20250423065135_create_reactions.rb
1
class CreateReactions < ActiveRecord::Migration[7.2]
2
  def change
3
    create_table :reactions do |t|
4
      t.references :reactable, polymorphic: true, null: false
5
      t.references :user, null: false
6
      t.timestamps null: false
7
    end
8
    add_index :reactions, [:reactable_type, :reactable_id, :user_id], unique: true
9
    add_index :reactions, [:reactable_type, :reactable_id, :id]
10
  end
11
end
lib/redmine/reaction.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Reaction
22
    # Types of objects that can have reactions
23
    REACTABLE_TYPES = %w(Journal Issue Message News Comment)
24

  
25
    # Returns true if the user can view the reaction information of the object
26
    def self.visible?(object, user = User.current)
27
      Setting.reactions_enabled? && object.visible?(user)
28
    end
29

  
30
    # Returns true if the user can add/remove a reaction to/from the object
31
    def self.writable?(object, user = User.current)
32
      user.logged? && visible?(object, user) && object&.project&.active?
33
    end
34

  
35
    module Reactable
36
      extend ActiveSupport::Concern
37

  
38
      included do
39
        has_many :reactions, as: :reactable, dependent: :delete_all
40

  
41
        attr_writer :reaction_detail
42
      end
43

  
44
      class_methods do
45
        # Preloads reaction details for a collection of objects
46
        def preload_reaction_details(objects)
47
          return unless Setting.reactions_enabled?
48

  
49
          details = ::Reaction.build_detail_map_for(objects, User.current)
50

  
51
          objects.each do |object|
52
            object.reaction_detail = details.fetch(object.id) { ::Reaction::Detail.new }
53
          end
54
        end
55
      end
56

  
57
      def reaction_detail
58
        # Loads and returns reaction details if they are not already loaded.
59
        # This is intended for cases where explicit preloading is unnecessary,
60
        # such as retrieving reactions for a single issue on its detail page.
61
        load_reaction_detail unless defined?(@reaction_detail)
62
        @reaction_detail
63
      end
64

  
65
      def load_reaction_detail
66
        self.class.preload_reaction_details([self])
67
      end
68
    end
69
  end
70
end
test/fixtures/reactions.yml
1
---
2
reaction_001:
3
  id: 1
4
  reactable_type: Issue
5
  reactable_id: 1
6
  user_id: 1
7
reaction_002:
8
  id: 2
9
  reactable_type: Issue
10
  reactable_id: 1
11
  user_id: 2
12
reaction_003:
13
  id: 3
14
  reactable_type: Issue
15
  reactable_id: 1
16
  user_id: 3
17
reaction_004:
18
  id: 4
19
  reactable_type: Journal
20
  reactable_id: 1
21
  user_id: 2
22
reaction_005:
23
  id: 5
24
  reactable_type: Issue
25
  reactable_id: 6
26
  user_id: 2
27
reaction_006:
28
  id: 6
29
  reactable_type: Journal
30
  reactable_id: 4
31
  user_id: 2
32
reaction_007:
33
  id: 7
34
  reactable_type: News
35
  reactable_id: 1
36
  user_id: 1
37
reaction_008:
38
  id: 8
39
  reactable_type: Comment
40
  reactable_id: 1
41
  user_id: 2
42
reaction_009:
43
  id: 9
44
  reactable_type: Message
45
  reactable_id: 7
46
  user_id: 2
47
reaction_010:
48
  id: 10
49
  reactable_type: News
50
  reactable_id: 3
51
  user_id: 2
test/functional/issues_controller_test.rb
3331 3331
    assert_select 'span.badge.badge-private', text: 'Private'
3332 3332
  end
3333 3333

  
3334
  def test_show_should_display_reactions
3335
    current_user = User.generate!
3336

  
3337
    User.add_to_project(current_user, projects(:projects_001),
3338
      Role.generate!(users_visibility: 'members_of_visible_projects', permissions: [:view_issues]))
3339

  
3340
    @request.session[:user_id] = current_user.id
3341

  
3342
    get :show, params: { id: 1 }
3343

  
3344
    assert_response :success
3345

  
3346
    assert_select 'span[data-reaction-button-id=reaction_issue_1]' do
3347
      # The current_user can only see members who belong to projects that the current_user has access to.
3348
      # Since the Redmine Admin user does not belong to any projects visible to the current_user,
3349
      # the Redmine Admin user's name is not displayed in the reaction user list. Instead, "1 other" is shown.
3350
      assert_select 'a.reaction-button[title=?]', 'Dave Lopper, John Smith, and 1 other' do
3351
        assert_select 'span.icon-label', '3'
3352
      end
3353
    end
3354

  
3355
    assert_select 'span[data-reaction-button-id=reaction_journal_1]' do
3356
      assert_select 'a.reaction-button[title=?]', 'John Smith'
3357
    end
3358
    assert_select 'span[data-reaction-button-id=reaction_journal_2] a.reaction-button'
3359
  end
3360

  
3361
  def test_should_not_display_reactions_when_reactions_feature_is_disabled
3362
    with_settings reactions_enabled: '0' do
3363
      get :show, params: { id: 1 }
3364

  
3365
      assert_response :success
3366
      assert_select 'span[data-reaction-button-id]', false
3367
    end
3368
  end
3369

  
3334 3370
  def test_show_should_not_display_edit_attachment_icon_for_user_without_edit_issue_permission_on_tracker
3335 3371
    role = Role.find(2)
3336 3372
    role.set_permission_trackers 'edit_issues', [2, 3]
test/functional/messages_controller_test.rb
123 123
    assert_select 'h3', {text: /Watchers \(\d*\)/, count: 0}
124 124
  end
125 125

  
126
  def test_show_should_display_reactions
127
    @request.session[:user_id] = 2
128

  
129
    get :show, params: { board_id: 1, id: 4 }
130

  
131
    assert_response :success
132
    assert_select 'span[data-reaction-button-id=reaction_message_4] a.reaction-button' do
133
      assert_select 'svg use[href*=thumb-up]'
134
    end
135
    assert_select 'span[data-reaction-button-id=reaction_message_5] a.reaction-button'
136
    assert_select 'span[data-reaction-button-id=reaction_message_6] a.reaction-button'
137

  
138
    # Should not display reactions when reactions feature is disabled.
139
    with_settings reactions_enabled: '0' do
140
      get :show, params: { board_id: 1, id: 4 }
141

  
142
      assert_response :success
143
      assert_select 'span[data-reaction-button-id]', false
144
    end
145
  end
146

  
126 147
  def test_get_new
127 148
    @request.session[:user_id] = 2
128 149
    get(:new, :params => {:board_id => 1})
test/functional/news_controller_test.rb
106 106
    assert_response :not_found
107 107
  end
108 108

  
109
  def test_show_should_display_reactions
110
    @request.session[:user_id] = 1
111

  
112
    get :show, params: { id: 1 }
113
    assert_response :success
114
    assert_select 'span[data-reaction-button-id=reaction_news_1] a.reaction-button.reacted'
115
    assert_select 'span[data-reaction-button-id=reaction_comment_1] a.reaction-button'
116

  
117
    # Should not display reactions when reactions feature is disabled.
118
    with_settings reactions_enabled: '0' do
119
      get :show, params: { id: 1 }
120

  
121
      assert_response :success
122
      assert_select 'span[data-reaction-button-id]', false
123
    end
124
  end
125

  
109 126
  def test_get_new_with_project_id
110 127
    @request.session[:user_id] = 2
111 128
    get(:new, :params => {:project_id => 1})
test/functional/reactions_controller_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../test_helper'
21

  
22
class ReactionsControllerTest < Redmine::ControllerTest
23
  setup do
24
    Setting.reactions_enabled = '1'
25
    # jsmith
26
    @request.session[:user_id] = users(:users_002).id
27
  end
28

  
29
  teardown do
30
    Setting.clear_cache
31
  end
32

  
33
  test 'create for issue' do
34
    issue = issues(:issues_002)
35

  
36
    assert_difference(
37
      ->{ Reaction.count } => 1,
38
      ->{ issue.reactions.by(users(:users_002)).count } => 1
39
    ) do
40
      post :create, params: {
41
        object_type: 'Issue',
42
        object_id: issue.id
43
      }, xhr: true
44
    end
45

  
46
    assert_response :success
47
  end
48

  
49
  test 'create for journal' do
50
    journal = journals(:journals_005)
51

  
52
    assert_difference(
53
      ->{ Reaction.count } => 1,
54
      ->{ journal.reactions.by(users(:users_002)).count } => 1
55
    ) do
56
      post :create, params: {
57
        object_type: 'Journal',
58
        object_id: journal.id
59
      }, xhr: true
60
    end
61

  
62
    assert_response :success
63
  end
64

  
65
  test 'create for news' do
66
    news = news(:news_002)
67

  
68
    assert_difference(
69
      ->{ Reaction.count } => 1,
70
      ->{ news.reactions.by(users(:users_002)).count } => 1
71
    ) do
72
      post :create, params: {
73
        object_type: 'News',
74
        object_id: news.id
75
      }, xhr: true
76
    end
77

  
78
    assert_response :success
79
  end
80

  
81
  test 'create reaction for comment' do
82
    comment = comments(:comments_002)
83

  
84
    assert_difference(
85
      ->{ Reaction.count } => 1,
86
      ->{ comment.reactions.by(users(:users_002)).count } => 1
87
    ) do
88
      post :create, params: {
89
        object_type: 'Comment',
90
        object_id: comment.id
91
      }, xhr: true
92
    end
93

  
94
    assert_response :success
95
  end
96

  
97
  test 'create for message' do
98
    message = messages(:messages_001)
99

  
100
    assert_difference(
101
      ->{ Reaction.count } => 1,
102
      ->{ message.reactions.by(users(:users_002)).count } => 1
103
    ) do
104
      post :create, params: {
105
        object_type: 'Message',
106
        object_id: message.id
107
      }, xhr: true
108
    end
109

  
110
    assert_response :success
111
  end
112

  
113
  test 'destroy for issue' do
114
    reaction = reactions(:reaction_005)
115

  
116
    assert_difference 'Reaction.count', -1 do
117
      delete :destroy, params: {
118
        id: reaction.id,
119
        # Issue (id=6)
120
        object_type: reaction.reactable_type,
121
        object_id: reaction.reactable_id
122
      }, xhr: true
123
    end
124

  
125
    assert_response :success
126
    assert_not Reaction.exists?(reaction.id)
127
  end
128

  
129
  test 'destroy for journal' do
130
    reaction = reactions(:reaction_006)
131

  
132
    assert_difference 'Reaction.count', -1 do
133
      delete :destroy, params: {
134
        id: reaction.id,
135
        object_type: reaction.reactable_type,
136
        object_id: reaction.reactable_id
137
      }, xhr: true
138
    end
139

  
140
    assert_response :success
141
    assert_not Reaction.exists?(reaction.id)
142
  end
143

  
144
  test 'destroy for news' do
145
    # For News(id=3)
146
    reaction = reactions(:reaction_010)
147

  
148
    assert_difference 'Reaction.count', -1 do
149
      delete :destroy, params: {
150
        id: reaction.id,
151
        object_type: reaction.reactable_type,
152
        object_id: reaction.reactable_id
153
      }, xhr: true
154
    end
155

  
156
    assert_response :success
157
    assert_not Reaction.exists?(reaction.id)
158
  end
159

  
160
  test 'destroy for comment' do
161
    # For Comment(id=1)
162
    reaction = reactions(:reaction_008)
163

  
164
    assert_difference 'Reaction.count', -1 do
165
      delete :destroy, params: {
166
        id: reaction.id,
167
        object_type: reaction.reactable_type,
168
        object_id: reaction.reactable_id
169
      }, xhr: true
170
    end
171

  
172
    assert_response :success
173
    assert_not Reaction.exists?(reaction.id)
174
  end
175

  
176
  test 'destroy for message' do
177
    reaction = reactions(:reaction_009)
178

  
179
    assert_difference 'Reaction.count', -1 do
180
      delete :destroy, params: {
181
        id: reaction.id,
182
        object_type: reaction.reactable_type,
183
        object_id: reaction.reactable_id
184
      }, xhr: true
185
    end
186

  
187
    assert_response :success
188
    assert_not Reaction.exists?(reaction.id)
189
  end
190

  
191
  test 'create should respond with 403 when feature is disabled' do
192
    Setting.reactions_enabled = '0'
193
    # admin
194
    @request.session[:user_id] = users(:users_001).id
195

  
196
    assert_no_difference 'Reaction.count' do
197
      post :create, params: {
198
        object_type: 'Issue',
199
        object_id: issues(:issues_002).id
200
      }, xhr: true
201
    end
202
    assert_response :forbidden
203
  end
204

  
205
  test 'destroy should respond with 403 when feature is disabled' do
206
    Setting.reactions_enabled = '0'
207
    # admin
208
    @request.session[:user_id] = users(:users_001).id
209

  
210
    reaction = reactions(:reaction_001)
211
    assert_no_difference 'Reaction.count' do
212
      delete :destroy, params: {
213
        id: reaction.id,
214
        object_type: reaction.reactable_type,
215
        object_id: reaction.reactable_id
216
      }, xhr: true
217
    end
218
    assert_response :forbidden
219
  end
220

  
221
  test 'create by anonymou user should respond with 401 when feature is disabled' do
222
    Setting.reactions_enabled = '0'
223
    @request.session[:user_id] = nil
224

  
225
    assert_no_difference 'Reaction.count' do
226
      post :create, params: {
227
        object_type: 'Issue',
228
        object_id: issues(:issues_002).id
229
      }, xhr: true
230
    end
231
    assert_response :unauthorized
232
  end
233

  
234
  test 'create by anonymous user should respond with 401' do
235
    @request.session[:user_id] = nil
236

  
237
    assert_no_difference 'Reaction.count' do
238
      post :create, params: {
239
        object_type: 'Issue',
240
        # Issue(id=1) is an issue in a public project
241
        object_id: issues(:issues_001).id
242
      }, xhr: true
243
    end
244

  
245
    assert_response :unauthorized
246
  end
247

  
248
  test 'destroy by anonymous user should respond with 401' do
249
    @request.session[:user_id] = nil
250

  
251
    reaction = reactions(:reaction_002)
252
    assert_no_difference 'Reaction.count' do
253
      delete :destroy, params: {
254
        id: reaction.id,
255
        object_type: reaction.reactable_type,
256
        object_id: reaction.reactable_id
257
      }, xhr: true
258
    end
259

  
260
    assert_response :unauthorized
261
  end
262

  
263
  test 'create when reaction already exists should not create a new reaction and succeed' do
264
    assert_no_difference 'Reaction.count' do
265
      post :create, params: {
266
        object_type: 'Comment',
267
        # user(jsmith) has already reacted to Comment(id=1)
268
        object_id: comments(:comments_001).id
269
      }, xhr: true
270
    end
271

  
272
    assert_response :success
273
  end
274

  
275
  test 'destroy another user reaction should not destroy the reaction and succeed' do
276
    # admin user's reaction
277
    reaction = reactions(:reaction_001)
278

  
279
    assert_no_difference 'Reaction.count' do
280
      delete :destroy, params: {
281
        id: reaction.id,
282
        object_type: reaction.reactable_type,
283
        object_id: reaction.reactable_id
284
      }, xhr: true
285
    end
286

  
287
    assert_response :success
288
  end
289

  
290
  test 'destroy nonexistent reaction' do
291
    # For Journal(id=4)
292
    reaction = reactions(:reaction_006)
293
    reaction.destroy!
294

  
295
    assert_not Reaction.exists?(reaction.id)
296

  
297
    assert_no_difference 'Reaction.count' do
298
      delete :destroy, params: {
299
        id: reaction.id,
300
        object_type: reaction.reactable_type,
301
        object_id: reaction.reactable_id
302
      }, xhr: true
303
    end
304

  
305
    assert_response :success
306
  end
307

  
308
  test 'create with invalid object type should respond with 403' do
309
    # admin
310
    @request.session[:user_id] = users(:users_001).id
311

  
312
    post :create, params: {
313
      object_type: 'InvalidType',
314
      object_id: 1
315
    }, xhr: true
316

  
317
    assert_response :forbidden
318
  end
319

  
320
  test 'create without permission to view should respond with 403' do
321
    # dlopper
322
    @request.session[:user_id] = users(:users_003).id
323

  
324
    assert_no_difference 'Reaction.count' do
325
      post :create, params: {
326
        object_type: 'Issue',
327
        # dlopper is not a member of the project where the issue (id=4) belongs.
328
        object_id: issues(:issues_004).id
329
      }, xhr: true
330
    end
331

  
332
    assert_response :forbidden
333
  end
334

  
335
  test 'destroy without permission to view should respond with 403' do
336
    # dlopper
337
    @request.session[:user_id] = users(:users_003).id
338

  
339
    # For Issue(id=6)
340
    reaction = reactions(:reaction_005)
341

  
342
    assert_no_difference 'Reaction.count' do
343
      delete :destroy, params: {
344
        id: reaction.id,
345
        object_type: reaction.reactable_type,
346
        object_id: reaction.reactable_id
347
      }, xhr: true
348
    end
349

  
350
    assert_response :forbidden
351
  end
352

  
353
  test 'create should respond with 404 for non-JS requests' do
354
    issue = issues(:issues_002)
355

  
356
    assert_no_difference 'Reaction.count' do
357
      post :create, params: {
358
        object_type: 'Issue',
359
        object_id: issue.id
360
      } # Sending an HTML request by omitting xhr: true
361
    end
362

  
363
    assert_response :not_found
364
  end
365

  
366
  test 'create should respond with 403 when project is closed' do
367
    issue = issues(:issues_010)
368
    issue.project.update!(status: Project::STATUS_CLOSED)
369

  
370
    assert_no_difference 'Reaction.count' do
371
      post :create, params: {
372
        object_type: 'Issue',
373
        object_id: issue.id
374
      }, xhr: true
375
    end
376

  
377
    assert_response :forbidden
378
  end
379

  
380
  test 'destroy should respond with 403 when project is closed' do
381
    reaction = reactions(:reaction_005)
382
    reaction.reactable.project.update!(status: Project::STATUS_CLOSED)
383

  
384
    assert_no_difference 'Reaction.count' do
385
      delete :destroy, params: {
386
        id: reaction.id,
387
        object_type: reaction.reactable_type,
388
        object_id: reaction.reactable_id
389
      }, xhr: true
390
    end
391

  
392
    assert_response :forbidden
393
  end
394
end
test/helpers/reactions_helper_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../test_helper'
21

  
22
class ReactionsHelperTest < ActionView::TestCase
23
  include ReactionsHelper
24

  
25
  setup do
26
    User.current = users(:users_002)
27
    Setting.reactions_enabled = '1'
28
  end
29

  
30
  teardown do
31
    Setting.clear_cache
32
  end
33

  
34
  test 'reaction_id_for generates a DOM id' do
35
    assert_equal "reaction_issue_1", reaction_id_for(issues(:issues_001))
36
  end
37

  
38
  test 'reaction_button returns nil when feature is disabled' do
39
    Setting.reactions_enabled = '0'
40

  
41
    assert_nil reaction_button(issues(:issues_004))
42
  end
43

  
44
  test 'reaction_button returns nil when object not visible' do
45
    User.current = users(:users_003)
46

  
47
    assert_nil reaction_button(issues(:issues_004))
48
  end
49

  
50
  test 'reaction_button for anonymous users shows readonly button' do
51
    User.current = nil
52

  
53
    result = reaction_button(journals(:journals_001))
54

  
55
    assert_select_in result, 'span.reaction-button.readonly[title=?]', 'John Smith'
56
    assert_select_in result, 'a.reaction-button', false
57
  end
58

  
59
  test 'reaction_button for inactive projects shows readonly button' do
60
    issue6 = issues(:issues_006)
61
    issue6.project.update!(status: Project::STATUS_CLOSED)
62

  
63
    result = reaction_button(issue6)
64

  
65
    assert_select_in result, 'span.reaction-button.readonly[title=?]', 'John Smith'
66
    assert_select_in result, 'a.reaction-button', false
67
  end
68

  
69
  test 'reaction_button includes no tooltip when the object has no reactions' do
70
    issue = issues(:issues_002) # Issue without reactions
71
    result = reaction_button(issue)
72

  
73
    assert_select_in result, 'a.reaction-button[title]', false
74
  end
75

  
76
  test 'reaction_button includes tooltip with all usernames when reactions are 10 or fewer' do
77
    issue = issues(:issues_002)
78

  
79
    reactions = build_reactions(10)
80
    issue.reactions += reactions
81

  
82
    result = with_locale 'en' do
83
      reaction_button(issue)
84
    end
85

  
86
    # The tooltip should display usernames in order of newest reactions.
87
    expected_tooltip = 'Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, Bob5 Doe, ' \
88
                       'Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and Bob0 Doe'
89

  
90
    assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip
91
  end
92

  
93
  test 'reaction_button includes tooltip with 10 usernames and others count when reactions exceed 10' do
94
    issue = issues(:issues_002)
95

  
96
    reactions = build_reactions(11)
97
    issue.reactions += reactions
98

  
99
    result = with_locale 'en' do
100
      reaction_button(issue)
101
    end
102

  
103
    expected_tooltip = 'Bob10 Doe, Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, ' \
104
                       'Bob5 Doe, Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and 1 other'
105

  
106
    assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip
107
  end
108

  
109
  test 'reaction_button displays non-visible users as "X other" in the tooltip' do
110
    issue2 = issues(:issues_002)
111

  
112
    issue2.reaction_detail = Reaction::Detail.new(
113
      # The remaining 3 users are non-visible users
114
      reaction_count: 5,
115
      visible_users: users(:users_002, :users_003)
116
    )
117

  
... This diff was truncated because it exceeds the maximum size that can be displayed.
(16-16/26)