summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGo MAEDA <maeda@farend.jp>2025-05-11 07:59:16 +0000
committerGo MAEDA <maeda@farend.jp>2025-05-11 07:59:16 +0000
commit403c10091f05aa88105439e970bb8985f2f78bec (patch)
tree69b04a6c6780ac4a15b1b7da982537aa220b032b
parentb650804fe902de02b8b1b6758deca642a4cc1c59 (diff)
downloadredmine-403c10091f05aa88105439e970bb8985f2f78bec.tar.gz
redmine-403c10091f05aa88105439e970bb8985f2f78bec.zip
Introduce reactions feature (so-called "like button") to issues, notes, news, and forums (#42630).
Patch by Katsuya HIDAKA (user:hidakatsuya). git-svn-id: https://svn.redmine.org/redmine/trunk@23755 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r--app/assets/images/icons.svg7
-rw-r--r--app/assets/javascripts/application-legacy.js8
-rw-r--r--app/assets/stylesheets/application.css39
-rw-r--r--app/controllers/messages_controller.rb2
-rw-r--r--app/controllers/news_controller.rb4
-rw-r--r--app/controllers/reactions_controller.rb65
-rw-r--r--app/helpers/issues_helper.rb1
-rw-r--r--app/helpers/journals_helper.rb3
-rw-r--r--app/helpers/messages_helper.rb1
-rw-r--r--app/helpers/news_helper.rb1
-rw-r--r--app/helpers/reactions_helper.rb100
-rw-r--r--app/models/comment.rb8
-rw-r--r--app/models/issue.rb7
-rw-r--r--app/models/journal.rb1
-rw-r--r--app/models/message.rb2
-rw-r--r--app/models/news.rb2
-rw-r--r--app/models/reaction.rb65
-rw-r--r--app/models/user.rb1
-rw-r--r--app/views/issues/show.html.erb3
-rw-r--r--app/views/messages/show.html.erb4
-rw-r--r--app/views/news/show.html.erb16
-rw-r--r--app/views/reactions/_replace_button.js.erb7
-rw-r--r--app/views/reactions/create.js.erb1
-rw-r--r--app/views/reactions/destroy.js.erb1
-rw-r--r--app/views/settings/_general.html.erb2
-rw-r--r--config/icon_source.yml7
-rw-r--r--config/locales/en.yml4
-rw-r--r--config/locales/ja.yml4
-rw-r--r--config/routes.rb2
-rw-r--r--config/settings.yml2
-rw-r--r--db/migrate/20250423065135_create_reactions.rb11
-rw-r--r--lib/redmine/reaction.rb70
-rw-r--r--test/fixtures/reactions.yml51
-rw-r--r--test/functional/issues_controller_test.rb36
-rw-r--r--test/functional/messages_controller_test.rb21
-rw-r--r--test/functional/news_controller_test.rb17
-rw-r--r--test/functional/reactions_controller_test.rb394
-rw-r--r--test/helpers/reactions_helper_test.rb196
-rw-r--r--test/system/reactions_test.rb132
-rw-r--r--test/unit/lib/redmine/reaction_test.rb193
-rw-r--r--test/unit/reaction_test.rb120
-rw-r--r--test/unit/user_test.rb12
42 files changed, 1612 insertions, 11 deletions
diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg
index 55e3a042d..2b0c9a41b 100644
--- a/app/assets/images/icons.svg
+++ b/app/assets/images/icons.svg
@@ -459,6 +459,13 @@
<path d="M19 15v6h3"/>
<path d="M11 21v-6l2.5 3l2.5 -3v6"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--thumb-up">
+ <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"/>
+ </symbol>
+ <symbol viewBox="0 0 24 24" id="icon--thumb-up-filled">
+ <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"/>
+ <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"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--time">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
<path d="M12 7v5l3 3"/>
diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js
index 265ac39c6..286e3e2e6 100644
--- a/app/assets/javascripts/application-legacy.js
+++ b/app/assets/javascripts/application-legacy.js
@@ -1222,8 +1222,8 @@ function setupWikiTableSortableHeader() {
});
}
-function setupHoverTooltips() {
- $("[title]:not(.no-tooltip)").tooltip({
+function setupHoverTooltips(container) {
+ $(container || 'body').find("[title]:not(.no-tooltip)").tooltip({
show: {
delay: 400
},
@@ -1233,7 +1233,9 @@ function setupHoverTooltips() {
}
});
}
-
+function removeHoverTooltips(container) {
+ $(container || 'body').find("[title]:not(.no-tooltip)").tooltip('destroy')
+}
$(function() { setupHoverTooltips(); });
function inlineAutoComplete(element) {
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 8bcfb2fb1..2647abf04 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -2113,6 +2113,45 @@ color: #555; text-shadow: 1px 1px 0 #fff;
img.filecontent.image {background-image: url(/transparent.png);}
+/* Reaction styles */
+.reaction-button.reacted .icon-svg {
+ fill: #126fa7;
+ stroke: none;
+}
+.reaction-button.reacted:hover .icon-svg {
+ fill: #c61a1a;
+}
+.reaction-button .icon-label {
+ margin-left: 3px;
+ margin-bottom: -1px;
+}
+.reaction-button.readonly {
+ cursor: default;
+}
+.reaction-button.readonly .icon-svg {
+ stroke: #999;
+}
+.reaction-button.readonly .icon-label {
+ color: #999;
+}
+div.issue.details .reaction {
+ float: right;
+ font-size: 0.9em;
+ margin-top: 0.5em;
+ margin-left: 10px;
+ clear: right;
+}
+div.message .reaction {
+ float: right;
+ font-size: 0.9em;
+ margin-left: 10px;
+}
+div.news .reaction {
+ float: right;
+ font-size: 0.9em;
+ margin-left: 10px;
+}
+
/* Custom JQuery styles */
.ui-autocomplete, .ui-menu {
border-radius: 2px;
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
index 22daf9f90..8b26bee73 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -51,6 +51,8 @@ class MessagesController < ApplicationController
offset(@reply_pages.offset).
to_a
+ Message.preload_reaction_details(@replies)
+
@reply = Message.new(:subject => "RE: #{@message.subject}")
render :action => "show", :layout => false if request.xhr?
end
diff --git a/app/controllers/news_controller.rb b/app/controllers/news_controller.rb
index 06240e359..dd6bade24 100644
--- a/app/controllers/news_controller.rb
+++ b/app/controllers/news_controller.rb
@@ -67,8 +67,10 @@ class NewsController < ApplicationController
end
def show
- @comments = @news.comments.to_a
+ @comments = @news.comments.preload(:commented).to_a
@comments.reverse! if User.current.wants_comments_in_reverse_order?
+
+ Comment.preload_reaction_details(@comments)
end
def new
diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb
new file mode 100644
index 000000000..f768f939d
--- /dev/null
+++ b/app/controllers/reactions_controller.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+class ReactionsController < ApplicationController
+ before_action :require_login
+
+ before_action :check_enabled
+ before_action :set_object, :authorize_reactable
+
+ def create
+ respond_to do |format|
+ format.js do
+ @object.reactions.find_or_create_by!(user: User.current)
+ end
+ format.any { head :not_found }
+ end
+ end
+
+ def destroy
+ respond_to do |format|
+ format.js do
+ reaction = @object.reactions.by(User.current).find_by(id: params[:id])
+ reaction&.destroy
+ end
+ format.any { head :not_found }
+ end
+ end
+
+ private
+
+ def check_enabled
+ render_403 unless Setting.reactions_enabled?
+ end
+
+ def set_object
+ object_type = params[:object_type]
+
+ unless Redmine::Reaction::REACTABLE_TYPES.include?(object_type)
+ render_403
+ return
+ end
+
+ @object = object_type.constantize.find(params[:object_id])
+ end
+
+ def authorize_reactable
+ render_403 unless Redmine::Reaction.writable?(@object, User.current)
+ end
+end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 6586a1b7e..ce3607a5d 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -22,6 +22,7 @@ module IssuesHelper
include Redmine::Export::PDF::IssuesPdfHelper
include IssueStatusesHelper
include QueriesHelper
+ include ReactionsHelper
def issue_list(issues, &)
ancestors = []
diff --git a/app/helpers/journals_helper.rb b/app/helpers/journals_helper.rb
index 6c22fc4ca..66f81033e 100644
--- a/app/helpers/journals_helper.rb
+++ b/app/helpers/journals_helper.rb
@@ -19,6 +19,7 @@
module JournalsHelper
include Redmine::QuoteReply::Helper
+ include ReactionsHelper
# Returns the attachments of a journal that are displayed as thumbnails
def journal_thumbnail_attachments(journal)
@@ -41,6 +42,8 @@ module JournalsHelper
end
if journal.notes.present?
+ links << reaction_button(journal)
+
if options[:reply_links]
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb
index fd9ba3bcb..92f788d0c 100644
--- a/app/helpers/messages_helper.rb
+++ b/app/helpers/messages_helper.rb
@@ -19,4 +19,5 @@
module MessagesHelper
include Redmine::QuoteReply::Helper
+ include ReactionsHelper
end
diff --git a/app/helpers/news_helper.rb b/app/helpers/news_helper.rb
index a5c50fdfd..cd7b6734a 100644
--- a/app/helpers/news_helper.rb
+++ b/app/helpers/news_helper.rb
@@ -18,4 +18,5 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module NewsHelper
+ include ReactionsHelper
end
diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb
new file mode 100644
index 000000000..911da7127
--- /dev/null
+++ b/app/helpers/reactions_helper.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+module ReactionsHelper
+ # Maximum number of users to display in the reaction button tooltip
+ DISPLAY_REACTION_USERS_LIMIT = 10
+
+ def reaction_button(object)
+ return unless Redmine::Reaction.visible?(object, User.current)
+
+ detail = object.reaction_detail
+
+ reaction = detail.user_reaction
+ count = detail.reaction_count
+ visible_user_names = detail.visible_users.take(DISPLAY_REACTION_USERS_LIMIT).map(&:name)
+
+ tooltip = build_reaction_tooltip(visible_user_names, count)
+
+ if Redmine::Reaction.writable?(object, User.current)
+ if reaction&.persisted?
+ reaction_button_reacted(object, reaction, count, tooltip)
+ else
+ reaction_button_not_reacted(object, count, tooltip)
+ end
+ else
+ reaction_button_readonly(object, count, tooltip)
+ end
+ end
+
+ def reaction_id_for(object)
+ dom_id(object, :reaction)
+ end
+
+ private
+
+ def reaction_button_reacted(object, reaction, count, tooltip)
+ reaction_button_wrapper object do
+ link_to(
+ sprite_icon('thumb-up-filled', count),
+ reaction_path(reaction, object_type: object.class.name, object_id: object),
+ remote: true, method: :delete,
+ class: ['icon', 'reaction-button', 'reacted'],
+ title: tooltip
+ )
+ end
+ end
+
+ def reaction_button_not_reacted(object, count, tooltip)
+ reaction_button_wrapper object do
+ link_to(
+ sprite_icon('thumb-up', count),
+ reactions_path(object_type: object.class.name, object_id: object),
+ remote: true, method: :post,
+ class: 'icon reaction-button',
+ title: tooltip
+ )
+ end
+ end
+
+ def reaction_button_readonly(object, count, tooltip)
+ reaction_button_wrapper object do
+ tag.span(class: 'icon reaction-button readonly', title: tooltip) do
+ sprite_icon('thumb-up', count)
+ end
+ end
+ end
+
+ def reaction_button_wrapper(object, &)
+ tag.span(data: { 'reaction-button-id': reaction_id_for(object) }, &)
+ end
+
+ def build_reaction_tooltip(visible_user_names, count)
+ return if count.zero?
+
+ display_user_names = visible_user_names.dup
+ others = count - visible_user_names.size
+
+ if others.positive?
+ display_user_names << I18n.t(:reaction_text_x_other_users, count: others)
+ end
+
+ display_user_names.to_sentence(locale: I18n.locale)
+ end
+end
diff --git a/app/models/comment.rb b/app/models/comment.rb
index 79eb59748..1716537af 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -19,6 +19,8 @@
class Comment < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
+
belongs_to :commented, :polymorphic => true, :counter_cache => true
belongs_to :author, :class_name => 'User'
@@ -28,6 +30,8 @@ class Comment < ApplicationRecord
safe_attributes 'comments'
+ delegate :visible?, to: :commented
+
def comments=(arg)
self.content = arg
end
@@ -36,6 +40,10 @@ class Comment < ApplicationRecord
content
end
+ def project
+ commented.respond_to?(:project) ? commented.project : nil
+ end
+
private
def send_notification
diff --git a/app/models/issue.rb b/app/models/issue.rb
index ac3b40bf1..bfef3533a 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -25,6 +25,7 @@ class Issue < ApplicationRecord
before_validation :clear_disabled_fields
before_save :set_parent_id
include Redmine::NestedSet::IssueNestedSet
+ include Redmine::Reaction::Reactable
belongs_to :project
belongs_to :tracker
@@ -916,7 +917,8 @@ class Issue < ApplicationRecord
result = journals.
preload(:details).
preload(:user => :email_address).
- reorder(:created_on, :id).to_a
+ reorder(:created_on, :id).
+ to_a
result.each_with_index {|j, i| j.indice = i + 1}
@@ -927,6 +929,9 @@ class Issue < ApplicationRecord
end
Journal.preload_journals_details_custom_fields(result)
result.select! {|journal| journal.notes? || journal.visible_details.any?}
+
+ Journal.preload_reaction_details(result)
+
result
end
diff --git a/app/models/journal.rb b/app/models/journal.rb
index 039b182e2..12f2beec8 100644
--- a/app/models/journal.rb
+++ b/app/models/journal.rb
@@ -19,6 +19,7 @@
class Journal < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
belongs_to :journalized, :polymorphic => true
# added as a quick fix to allow eager loading of the polymorphic association
diff --git a/app/models/message.rb b/app/models/message.rb
index c7f78d2d9..9ac88c7d1 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -19,6 +19,8 @@
class Message < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
+
belongs_to :board
belongs_to :author, :class_name => 'User'
acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
diff --git a/app/models/news.rb b/app/models/news.rb
index 40cd63db9..174e4c5ac 100644
--- a/app/models/news.rb
+++ b/app/models/news.rb
@@ -19,6 +19,8 @@
class News < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
+
belongs_to :project
belongs_to :author, :class_name => 'User'
has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all
diff --git a/app/models/reaction.rb b/app/models/reaction.rb
new file mode 100644
index 000000000..84c982043
--- /dev/null
+++ b/app/models/reaction.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+class Reaction < ApplicationRecord
+ belongs_to :reactable, polymorphic: true
+ belongs_to :user
+
+ validates :reactable_type, inclusion: { in: Redmine::Reaction::REACTABLE_TYPES }
+
+ scope :by, ->(user) { where(user: user) }
+ scope :for_reactable, ->(reactable) { where(reactable: reactable) }
+
+ # Represents reaction details for a reactable object
+ Detail = Struct.new(
+ # Total number of reactions
+ :reaction_count,
+ # Users who reacted and are visible to the target user
+ :visible_users,
+ # Reaction of the target user
+ :user_reaction
+ ) do
+ def initialize(reaction_count: 0, visible_users: [], user_reaction: nil)
+ super
+ end
+ end
+
+ def self.build_detail_map_for(reactables, user)
+ reactions = preload(:user)
+ .for_reactable(reactables)
+ .select(:id, :reactable_id, :user_id)
+ .order(id: :desc)
+
+ # Prepare IDs of users who reacted and are visible to the user
+ visible_user_ids = User.visible(user)
+ .joins(:reactions)
+ .where(reactions: for_reactable(reactables))
+ .pluck(:id).to_set
+
+ reactions.each_with_object({}) do |reaction, m|
+ m[reaction.reactable_id] ||= Detail.new
+
+ m[reaction.reactable_id].then do |detail|
+ detail.reaction_count += 1
+ detail.visible_users << reaction.user if visible_user_ids.include?(reaction.user.id)
+ detail.user_reaction = reaction if reaction.user == user
+ end
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 4b6387ae5..9f74a60fb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -92,6 +92,7 @@ class User < Principal
has_one :atom_token, lambda {where "#{table.name}.action='feeds'"}, :class_name => 'Token'
has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token'
has_many :email_addresses, :dependent => :delete_all
+ has_many :reactions, dependent: :delete_all
belongs_to :auth_source
scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")}
diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb
index 0a6da1098..e41a91bb3 100644
--- a/app/views/issues/show.html.erb
+++ b/app/views/issues/show.html.erb
@@ -47,6 +47,9 @@
</div>
</div>
+<div class="reaction">
+ <%= reaction_button @issue %>
+</div>
<p class="author">
<%= authoring @issue.created_on, @issue.author %>.
<% if @issue.created_on != @issue.updated_on %>
diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb
index b265cc962..b62709afa 100644
--- a/app/views/messages/show.html.erb
+++ b/app/views/messages/show.html.erb
@@ -27,6 +27,9 @@
<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
<div class="message">
+<div class="reaction">
+ <%= reaction_button @topic %>
+</div>
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
<div id="message_topic_wiki" class="wiki">
<%= textilizable(@topic, :content) %>
@@ -44,6 +47,7 @@
<% @replies.each do |message| %>
<div class="message reply" id="<%= "message-#{message.id}" %>">
<div class="contextual">
+ <%= reaction_button message %>
<%= quote_reply(
url_for(:action => 'quote', :id => message, :format => 'js'),
"#message-#{message.id} .wiki",
diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb
index d07a09eb7..601f12072 100644
--- a/app/views/news/show.html.erb
+++ b/app/views/news/show.html.erb
@@ -22,12 +22,17 @@
</div>
<% end %>
-<p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
-<span class="author"><%= authoring @news.created_on, @news.author %></span></p>
-<div class="wiki">
-<%= textilizable(@news, :description) %>
+<div class="news">
+ <div class="reaction">
+ <%= reaction_button @news %>
+ </div>
+ <p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
+ <span class="author"><%= authoring @news.created_on, @news.author %></span></p>
+ <div class="wiki">
+ <%= textilizable(@news, :description) %>
+ </div>
+ <%= link_to_attachments @news %>
</div>
-<%= link_to_attachments @news %>
<br />
<div id="comments" style="margin-bottom:16px;">
@@ -38,6 +43,7 @@
<% @comments.each do |comment| %>
<% next if comment.new_record? %>
<div class="contextual">
+ <%= reaction_button comment %>
<%= link_to_if_authorized sprite_icon('del', l(:button_delete)), { :controller => 'comments', :action => 'destroy', :id => @news, :comment_id => comment},
:data => {:confirm => l(:text_are_you_sure)}, :method => :delete,
:title => l(:button_delete),
diff --git a/app/views/reactions/_replace_button.js.erb b/app/views/reactions/_replace_button.js.erb
new file mode 100644
index 000000000..a5c923ea4
--- /dev/null
+++ b/app/views/reactions/_replace_button.js.erb
@@ -0,0 +1,7 @@
+(() => {
+ const button = $('[data-reaction-button-id=<%= reaction_id_for @object %>]');
+
+ removeHoverTooltips(button);
+ button.html($('<%=j reaction_button @object %>').children());
+ setupHoverTooltips(button);
+})();
diff --git a/app/views/reactions/create.js.erb b/app/views/reactions/create.js.erb
new file mode 100644
index 000000000..20f3cc7ed
--- /dev/null
+++ b/app/views/reactions/create.js.erb
@@ -0,0 +1 @@
+<%= render 'replace_button' %>
diff --git a/app/views/reactions/destroy.js.erb b/app/views/reactions/destroy.js.erb
new file mode 100644
index 000000000..20f3cc7ed
--- /dev/null
+++ b/app/views/reactions/destroy.js.erb
@@ -0,0 +1 @@
+<%= render 'replace_button' %>
diff --git a/app/views/settings/_general.html.erb b/app/views/settings/_general.html.erb
index 043067f18..44206b6c2 100644
--- a/app/views/settings/_general.html.erb
+++ b/app/views/settings/_general.html.erb
@@ -37,6 +37,8 @@
<p><%= setting_text_field :feeds_limit, :size => 6 %></p>
+<p><%= setting_check_box :reactions_enabled %></p>
+
<%= call_hook(:view_settings_general_form) %>
</div>
diff --git a/config/icon_source.yml b/config/icon_source.yml
index dc1803cdc..6fc7c5567 100644
--- a/config/icon_source.yml
+++ b/config/icon_source.yml
@@ -221,4 +221,9 @@
- name: unwatch
svg: eye-off
- name: copy-pre-content
- svg: clipboard \ No newline at end of file
+ svg: clipboard
+- name: thumb-up
+ svg: thumb-up
+- name: thumb-up-filled
+ svg: thumb-up
+ style: filled
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 819846e1a..5f7291593 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -528,6 +528,7 @@ en:
setting_twofa: Two-factor authentication
setting_related_issues_default_columns: Related and sub issues list defaults
setting_display_related_issues_table_headers: Show table headers
+ setting_reactions_enabled: Enable reactions
permission_add_project: Create project
permission_add_subprojects: Create subprojects
@@ -1432,3 +1433,6 @@ en:
text_project_destroy_enter_identifier: "To confirm, please enter the project's identifier (%{identifier}) below."
field_name_or_email_or_login: Name, email or login
setting_wiki_tablesort_enabled: Javascript based table sorting in wiki content
+ reaction_text_x_other_users:
+ one: "1 other"
+ other: "%{count} others"
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index d72d136ec..9bb11c884 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -1457,3 +1457,7 @@ ja:
setting_related_issues_default_columns: 関連するチケットと子チケットの一覧で表示する項目
setting_display_related_issues_table_headers: テーブルヘッダを表示
error_can_not_remove_role_reason_members_html: "<p>以下のプロジェクトにこのロールのメンバーがいます:<br>%{projects}</p>"
+ setting_reactions_enabled: リアクション機能を有効にする
+ reaction_text_x_other_users:
+ one: 他1人
+ other: "他%{count}人"
diff --git a/config/routes.rb b/config/routes.rb
index 89927bee3..20a7e826a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -61,6 +61,8 @@ Rails.application.routes.draw do
end
end
+ resources :reactions, only: [:create, :destroy]
+
get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt'
get '/issues/gantt', :to => 'gantts#show'
diff --git a/config/settings.yml b/config/settings.yml
index a0c256cdd..cda40fa38 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -363,3 +363,5 @@ show_status_changes_in_mail_subject:
default: 1
wiki_tablesort_enabled:
default: 1
+reactions_enabled:
+ default: 1
diff --git a/db/migrate/20250423065135_create_reactions.rb b/db/migrate/20250423065135_create_reactions.rb
new file mode 100644
index 000000000..56f345e1b
--- /dev/null
+++ b/db/migrate/20250423065135_create_reactions.rb
@@ -0,0 +1,11 @@
+class CreateReactions < ActiveRecord::Migration[7.2]
+ def change
+ create_table :reactions do |t|
+ t.references :reactable, polymorphic: true, null: false
+ t.references :user, null: false
+ t.timestamps null: false
+ end
+ add_index :reactions, [:reactable_type, :reactable_id, :user_id], unique: true
+ add_index :reactions, [:reactable_type, :reactable_id, :id]
+ end
+end
diff --git a/lib/redmine/reaction.rb b/lib/redmine/reaction.rb
new file mode 100644
index 000000000..b6f2bf075
--- /dev/null
+++ b/lib/redmine/reaction.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+module Redmine
+ module Reaction
+ # Types of objects that can have reactions
+ REACTABLE_TYPES = %w(Journal Issue Message News Comment)
+
+ # Returns true if the user can view the reaction information of the object
+ def self.visible?(object, user = User.current)
+ Setting.reactions_enabled? && object.visible?(user)
+ end
+
+ # Returns true if the user can add/remove a reaction to/from the object
+ def self.writable?(object, user = User.current)
+ user.logged? && visible?(object, user) && object&.project&.active?
+ end
+
+ module Reactable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :reactions, as: :reactable, dependent: :delete_all
+
+ attr_writer :reaction_detail
+ end
+
+ class_methods do
+ # Preloads reaction details for a collection of objects
+ def preload_reaction_details(objects)
+ return unless Setting.reactions_enabled?
+
+ details = ::Reaction.build_detail_map_for(objects, User.current)
+
+ objects.each do |object|
+ object.reaction_detail = details.fetch(object.id) { ::Reaction::Detail.new }
+ end
+ end
+ end
+
+ def reaction_detail
+ # Loads and returns reaction details if they are not already loaded.
+ # This is intended for cases where explicit preloading is unnecessary,
+ # such as retrieving reactions for a single issue on its detail page.
+ load_reaction_detail unless defined?(@reaction_detail)
+ @reaction_detail
+ end
+
+ def load_reaction_detail
+ self.class.preload_reaction_details([self])
+ end
+ end
+ end
+end
diff --git a/test/fixtures/reactions.yml b/test/fixtures/reactions.yml
new file mode 100644
index 000000000..d8fcbfc1b
--- /dev/null
+++ b/test/fixtures/reactions.yml
@@ -0,0 +1,51 @@
+---
+reaction_001:
+ id: 1
+ reactable_type: Issue
+ reactable_id: 1
+ user_id: 1
+reaction_002:
+ id: 2
+ reactable_type: Issue
+ reactable_id: 1
+ user_id: 2
+reaction_003:
+ id: 3
+ reactable_type: Issue
+ reactable_id: 1
+ user_id: 3
+reaction_004:
+ id: 4
+ reactable_type: Journal
+ reactable_id: 1
+ user_id: 2
+reaction_005:
+ id: 5
+ reactable_type: Issue
+ reactable_id: 6
+ user_id: 2
+reaction_006:
+ id: 6
+ reactable_type: Journal
+ reactable_id: 4
+ user_id: 2
+reaction_007:
+ id: 7
+ reactable_type: News
+ reactable_id: 1
+ user_id: 1
+reaction_008:
+ id: 8
+ reactable_type: Comment
+ reactable_id: 1
+ user_id: 2
+reaction_009:
+ id: 9
+ reactable_type: Message
+ reactable_id: 7
+ user_id: 2
+reaction_010:
+ id: 10
+ reactable_type: News
+ reactable_id: 3
+ user_id: 2
diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb
index b5180fcff..b7e0321d4 100644
--- a/test/functional/issues_controller_test.rb
+++ b/test/functional/issues_controller_test.rb
@@ -3331,6 +3331,42 @@ class IssuesControllerTest < Redmine::ControllerTest
assert_select 'span.badge.badge-private', text: 'Private'
end
+ def test_show_should_display_reactions
+ current_user = User.generate!
+
+ User.add_to_project(current_user, projects(:projects_001),
+ Role.generate!(users_visibility: 'members_of_visible_projects', permissions: [:view_issues]))
+
+ @request.session[:user_id] = current_user.id
+
+ get :show, params: { id: 1 }
+
+ assert_response :success
+
+ assert_select 'span[data-reaction-button-id=reaction_issue_1]' do
+ # The current_user can only see members who belong to projects that the current_user has access to.
+ # Since the Redmine Admin user does not belong to any projects visible to the current_user,
+ # the Redmine Admin user's name is not displayed in the reaction user list. Instead, "1 other" is shown.
+ assert_select 'a.reaction-button[title=?]', 'Dave Lopper, John Smith, and 1 other' do
+ assert_select 'span.icon-label', '3'
+ end
+ end
+
+ assert_select 'span[data-reaction-button-id=reaction_journal_1]' do
+ assert_select 'a.reaction-button[title=?]', 'John Smith'
+ end
+ assert_select 'span[data-reaction-button-id=reaction_journal_2] a.reaction-button'
+ end
+
+ def test_should_not_display_reactions_when_reactions_feature_is_disabled
+ with_settings reactions_enabled: '0' do
+ get :show, params: { id: 1 }
+
+ assert_response :success
+ assert_select 'span[data-reaction-button-id]', false
+ end
+ end
+
def test_show_should_not_display_edit_attachment_icon_for_user_without_edit_issue_permission_on_tracker
role = Role.find(2)
role.set_permission_trackers 'edit_issues', [2, 3]
diff --git a/test/functional/messages_controller_test.rb b/test/functional/messages_controller_test.rb
index 74b9a3070..997b2263a 100644
--- a/test/functional/messages_controller_test.rb
+++ b/test/functional/messages_controller_test.rb
@@ -123,6 +123,27 @@ class MessagesControllerTest < Redmine::ControllerTest
assert_select 'h3', {text: /Watchers \(\d*\)/, count: 0}
end
+ def test_show_should_display_reactions
+ @request.session[:user_id] = 2
+
+ get :show, params: { board_id: 1, id: 4 }
+
+ assert_response :success
+ assert_select 'span[data-reaction-button-id=reaction_message_4] a.reaction-button' do
+ assert_select 'svg use[href*=thumb-up]'
+ end
+ assert_select 'span[data-reaction-button-id=reaction_message_5] a.reaction-button'
+ assert_select 'span[data-reaction-button-id=reaction_message_6] a.reaction-button'
+
+ # Should not display reactions when reactions feature is disabled.
+ with_settings reactions_enabled: '0' do
+ get :show, params: { board_id: 1, id: 4 }
+
+ assert_response :success
+ assert_select 'span[data-reaction-button-id]', false
+ end
+ end
+
def test_get_new
@request.session[:user_id] = 2
get(:new, :params => {:board_id => 1})
diff --git a/test/functional/news_controller_test.rb b/test/functional/news_controller_test.rb
index f1ddfff71..536814c9d 100644
--- a/test/functional/news_controller_test.rb
+++ b/test/functional/news_controller_test.rb
@@ -106,6 +106,23 @@ class NewsControllerTest < Redmine::ControllerTest
assert_response :not_found
end
+ def test_show_should_display_reactions
+ @request.session[:user_id] = 1
+
+ get :show, params: { id: 1 }
+ assert_response :success
+ assert_select 'span[data-reaction-button-id=reaction_news_1] a.reaction-button.reacted'
+ assert_select 'span[data-reaction-button-id=reaction_comment_1] a.reaction-button'
+
+ # Should not display reactions when reactions feature is disabled.
+ with_settings reactions_enabled: '0' do
+ get :show, params: { id: 1 }
+
+ assert_response :success
+ assert_select 'span[data-reaction-button-id]', false
+ end
+ end
+
def test_get_new_with_project_id
@request.session[:user_id] = 2
get(:new, :params => {:project_id => 1})
diff --git a/test/functional/reactions_controller_test.rb b/test/functional/reactions_controller_test.rb
new file mode 100644
index 000000000..b65794969
--- /dev/null
+++ b/test/functional/reactions_controller_test.rb
@@ -0,0 +1,394 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../test_helper'
+
+class ReactionsControllerTest < Redmine::ControllerTest
+ setup do
+ Setting.reactions_enabled = '1'
+ # jsmith
+ @request.session[:user_id] = users(:users_002).id
+ end
+
+ teardown do
+ Setting.clear_cache
+ end
+
+ test 'create for issue' do
+ issue = issues(:issues_002)
+
+ assert_difference(
+ ->{ Reaction.count } => 1,
+ ->{ issue.reactions.by(users(:users_002)).count } => 1
+ ) do
+ post :create, params: {
+ object_type: 'Issue',
+ object_id: issue.id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'create for journal' do
+ journal = journals(:journals_005)
+
+ assert_difference(
+ ->{ Reaction.count } => 1,
+ ->{ journal.reactions.by(users(:users_002)).count } => 1
+ ) do
+ post :create, params: {
+ object_type: 'Journal',
+ object_id: journal.id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'create for news' do
+ news = news(:news_002)
+
+ assert_difference(
+ ->{ Reaction.count } => 1,
+ ->{ news.reactions.by(users(:users_002)).count } => 1
+ ) do
+ post :create, params: {
+ object_type: 'News',
+ object_id: news.id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'create reaction for comment' do
+ comment = comments(:comments_002)
+
+ assert_difference(
+ ->{ Reaction.count } => 1,
+ ->{ comment.reactions.by(users(:users_002)).count } => 1
+ ) do
+ post :create, params: {
+ object_type: 'Comment',
+ object_id: comment.id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'create for message' do
+ message = messages(:messages_001)
+
+ assert_difference(
+ ->{ Reaction.count } => 1,
+ ->{ message.reactions.by(users(:users_002)).count } => 1
+ ) do
+ post :create, params: {
+ object_type: 'Message',
+ object_id: message.id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'destroy for issue' do
+ reaction = reactions(:reaction_005)
+
+ assert_difference 'Reaction.count', -1 do
+ delete :destroy, params: {
+ id: reaction.id,
+ # Issue (id=6)
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :success
+ assert_not Reaction.exists?(reaction.id)
+ end
+
+ test 'destroy for journal' do
+ reaction = reactions(:reaction_006)
+
+ assert_difference 'Reaction.count', -1 do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :success
+ assert_not Reaction.exists?(reaction.id)
+ end
+
+ test 'destroy for news' do
+ # For News(id=3)
+ reaction = reactions(:reaction_010)
+
+ assert_difference 'Reaction.count', -1 do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :success
+ assert_not Reaction.exists?(reaction.id)
+ end
+
+ test 'destroy for comment' do
+ # For Comment(id=1)
+ reaction = reactions(:reaction_008)
+
+ assert_difference 'Reaction.count', -1 do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :success
+ assert_not Reaction.exists?(reaction.id)
+ end
+
+ test 'destroy for message' do
+ reaction = reactions(:reaction_009)
+
+ assert_difference 'Reaction.count', -1 do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :success
+ assert_not Reaction.exists?(reaction.id)
+ end
+
+ test 'create should respond with 403 when feature is disabled' do
+ Setting.reactions_enabled = '0'
+ # admin
+ @request.session[:user_id] = users(:users_001).id
+
+ assert_no_difference 'Reaction.count' do
+ post :create, params: {
+ object_type: 'Issue',
+ object_id: issues(:issues_002).id
+ }, xhr: true
+ end
+ assert_response :forbidden
+ end
+
+ test 'destroy should respond with 403 when feature is disabled' do
+ Setting.reactions_enabled = '0'
+ # admin
+ @request.session[:user_id] = users(:users_001).id
+
+ reaction = reactions(:reaction_001)
+ assert_no_difference 'Reaction.count' do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+ assert_response :forbidden
+ end
+
+ test 'create by anonymou user should respond with 401 when feature is disabled' do
+ Setting.reactions_enabled = '0'
+ @request.session[:user_id] = nil
+
+ assert_no_difference 'Reaction.count' do
+ post :create, params: {
+ object_type: 'Issue',
+ object_id: issues(:issues_002).id
+ }, xhr: true
+ end
+ assert_response :unauthorized
+ end
+
+ test 'create by anonymous user should respond with 401' do
+ @request.session[:user_id] = nil
+
+ assert_no_difference 'Reaction.count' do
+ post :create, params: {
+ object_type: 'Issue',
+ # Issue(id=1) is an issue in a public project
+ object_id: issues(:issues_001).id
+ }, xhr: true
+ end
+
+ assert_response :unauthorized
+ end
+
+ test 'destroy by anonymous user should respond with 401' do
+ @request.session[:user_id] = nil
+
+ reaction = reactions(:reaction_002)
+ assert_no_difference 'Reaction.count' do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :unauthorized
+ end
+
+ test 'create when reaction already exists should not create a new reaction and succeed' do
+ assert_no_difference 'Reaction.count' do
+ post :create, params: {
+ object_type: 'Comment',
+ # user(jsmith) has already reacted to Comment(id=1)
+ object_id: comments(:comments_001).id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'destroy another user reaction should not destroy the reaction and succeed' do
+ # admin user's reaction
+ reaction = reactions(:reaction_001)
+
+ assert_no_difference 'Reaction.count' do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'destroy nonexistent reaction' do
+ # For Journal(id=4)
+ reaction = reactions(:reaction_006)
+ reaction.destroy!
+
+ assert_not Reaction.exists?(reaction.id)
+
+ assert_no_difference 'Reaction.count' do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :success
+ end
+
+ test 'create with invalid object type should respond with 403' do
+ # admin
+ @request.session[:user_id] = users(:users_001).id
+
+ post :create, params: {
+ object_type: 'InvalidType',
+ object_id: 1
+ }, xhr: true
+
+ assert_response :forbidden
+ end
+
+ test 'create without permission to view should respond with 403' do
+ # dlopper
+ @request.session[:user_id] = users(:users_003).id
+
+ assert_no_difference 'Reaction.count' do
+ post :create, params: {
+ object_type: 'Issue',
+ # dlopper is not a member of the project where the issue (id=4) belongs.
+ object_id: issues(:issues_004).id
+ }, xhr: true
+ end
+
+ assert_response :forbidden
+ end
+
+ test 'destroy without permission to view should respond with 403' do
+ # dlopper
+ @request.session[:user_id] = users(:users_003).id
+
+ # For Issue(id=6)
+ reaction = reactions(:reaction_005)
+
+ assert_no_difference 'Reaction.count' do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :forbidden
+ end
+
+ test 'create should respond with 404 for non-JS requests' do
+ issue = issues(:issues_002)
+
+ assert_no_difference 'Reaction.count' do
+ post :create, params: {
+ object_type: 'Issue',
+ object_id: issue.id
+ } # Sending an HTML request by omitting xhr: true
+ end
+
+ assert_response :not_found
+ end
+
+ test 'create should respond with 403 when project is closed' do
+ issue = issues(:issues_010)
+ issue.project.update!(status: Project::STATUS_CLOSED)
+
+ assert_no_difference 'Reaction.count' do
+ post :create, params: {
+ object_type: 'Issue',
+ object_id: issue.id
+ }, xhr: true
+ end
+
+ assert_response :forbidden
+ end
+
+ test 'destroy should respond with 403 when project is closed' do
+ reaction = reactions(:reaction_005)
+ reaction.reactable.project.update!(status: Project::STATUS_CLOSED)
+
+ assert_no_difference 'Reaction.count' do
+ delete :destroy, params: {
+ id: reaction.id,
+ object_type: reaction.reactable_type,
+ object_id: reaction.reactable_id
+ }, xhr: true
+ end
+
+ assert_response :forbidden
+ end
+end
diff --git a/test/helpers/reactions_helper_test.rb b/test/helpers/reactions_helper_test.rb
new file mode 100644
index 000000000..f3a4e38d8
--- /dev/null
+++ b/test/helpers/reactions_helper_test.rb
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../test_helper'
+
+class ReactionsHelperTest < ActionView::TestCase
+ include ReactionsHelper
+
+ setup do
+ User.current = users(:users_002)
+ Setting.reactions_enabled = '1'
+ end
+
+ teardown do
+ Setting.clear_cache
+ end
+
+ test 'reaction_id_for generates a DOM id' do
+ assert_equal "reaction_issue_1", reaction_id_for(issues(:issues_001))
+ end
+
+ test 'reaction_button returns nil when feature is disabled' do
+ Setting.reactions_enabled = '0'
+
+ assert_nil reaction_button(issues(:issues_004))
+ end
+
+ test 'reaction_button returns nil when object not visible' do
+ User.current = users(:users_003)
+
+ assert_nil reaction_button(issues(:issues_004))
+ end
+
+ test 'reaction_button for anonymous users shows readonly button' do
+ User.current = nil
+
+ result = reaction_button(journals(:journals_001))
+
+ assert_select_in result, 'span.reaction-button.readonly[title=?]', 'John Smith'
+ assert_select_in result, 'a.reaction-button', false
+ end
+
+ test 'reaction_button for inactive projects shows readonly button' do
+ issue6 = issues(:issues_006)
+ issue6.project.update!(status: Project::STATUS_CLOSED)
+
+ result = reaction_button(issue6)
+
+ assert_select_in result, 'span.reaction-button.readonly[title=?]', 'John Smith'
+ assert_select_in result, 'a.reaction-button', false
+ end
+
+ test 'reaction_button includes no tooltip when the object has no reactions' do
+ issue = issues(:issues_002) # Issue without reactions
+ result = reaction_button(issue)
+
+ assert_select_in result, 'a.reaction-button[title]', false
+ end
+
+ test 'reaction_button includes tooltip with all usernames when reactions are 10 or fewer' do
+ issue = issues(:issues_002)
+
+ reactions = build_reactions(10)
+ issue.reactions += reactions
+
+ result = with_locale 'en' do
+ reaction_button(issue)
+ end
+
+ # The tooltip should display usernames in order of newest reactions.
+ expected_tooltip = 'Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, Bob5 Doe, ' \
+ 'Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and Bob0 Doe'
+
+ assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip
+ end
+
+ test 'reaction_button includes tooltip with 10 usernames and others count when reactions exceed 10' do
+ issue = issues(:issues_002)
+
+ reactions = build_reactions(11)
+ issue.reactions += reactions
+
+ result = with_locale 'en' do
+ reaction_button(issue)
+ end
+
+ expected_tooltip = 'Bob10 Doe, Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, ' \
+ 'Bob5 Doe, Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and 1 other'
+
+ assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip
+ end
+
+ test 'reaction_button displays non-visible users as "X other" in the tooltip' do
+ issue2 = issues(:issues_002)
+
+ issue2.reaction_detail = Reaction::Detail.new(
+ # The remaining 3 users are non-visible users
+ reaction_count: 5,
+ visible_users: users(:users_002, :users_003)
+ )
+
+ result = with_locale('en') do
+ reaction_button(issue2)
+ end
+
+ assert_select_in result, 'a.reaction-button[title=?]', 'John Smith, Dave Lopper, and 3 others'
+
+ # When all users are non-visible users
+ issue2.reaction_detail = Reaction::Detail.new(
+ reaction_count: 2,
+ visible_users: []
+ )
+
+ result = with_locale('en') do
+ reaction_button(issue2)
+ end
+
+ assert_select_in result, 'a.reaction-button[title=?]', '2 others'
+ end
+
+ test 'reaction_button formats the tooltip content based on the support.array settings of each locale' do
+ result = with_locale('ja') do
+ reaction_button(issues(:issues_001))
+ end
+
+ assert_select_in result, 'a.reaction-button[title=?]', 'Dave Lopper、John Smith、Redmine Admin'
+ end
+
+ test 'reaction_button for reacted object' do
+ User.current = users(:users_002)
+
+ issue = issues(:issues_001)
+
+ result = with_locale('en') do
+ reaction_button(issue)
+ end
+ tooltip = 'Dave Lopper, John Smith, and Redmine Admin'
+
+ assert_select_in result, 'span[data-reaction-button-id=?]', 'reaction_issue_1' do
+ href = reaction_path(issue.reaction_detail.user_reaction, object_type: 'Issue', object_id: 1)
+
+ assert_select 'a.icon.reaction-button.reacted[href=?]', href do
+ assert_select 'use[href*=?]', 'thumb-up-filled'
+ assert_select 'span.icon-label', '3'
+ end
+
+ assert_select 'span.reaction-button', false
+ end
+ end
+
+ test 'reaction_button for non-reacted object' do
+ User.current = users(:users_004)
+
+ issue = issues(:issues_001)
+
+ result = with_locale('en') do
+ reaction_button(issue)
+ end
+ tooltip = 'Dave Lopper, John Smith, and Redmine Admin'
+
+ assert_select_in result, 'span[data-reaction-button-id=?]', 'reaction_issue_1' do
+ href = reactions_path(object_type: 'Issue', object_id: 1)
+
+ assert_select 'a.icon.reaction-button[href=?]', href do
+ assert_select 'use[href*=?]', 'thumb-up'
+ assert_select 'span.icon-label', '3'
+ end
+
+ assert_select 'span.reaction-button', false
+ end
+ end
+
+ private
+
+ def build_reactions(count)
+ Array.new(count) do |i|
+ Reaction.new(user: User.generate!(firstname: "Bob#{i}"))
+ end
+ end
+end
diff --git a/test/system/reactions_test.rb b/test/system/reactions_test.rb
new file mode 100644
index 000000000..01ba76832
--- /dev/null
+++ b/test/system/reactions_test.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../application_system_test_case'
+
+class ReactionsSystemTest < ApplicationSystemTestCase
+ def test_react_to_issue
+ log_user('jsmith', 'jsmith')
+
+ issue = issues(:issues_002)
+
+ with_settings(reactions_enabled: '1') do
+ visit '/issues/2'
+ reaction_button = find("div.issue.details [data-reaction-button-id=\"reaction_issue_#{issue.id}\"]")
+ assert_reaction_add_and_remove(reaction_button, issue)
+ end
+ end
+
+ def test_react_to_journal
+ log_user('jsmith', 'jsmith')
+
+ journal = journals(:journals_002)
+
+ with_settings(reactions_enabled: '1') do
+ visit '/issues/1'
+ reaction_button = find("[data-reaction-button-id=\"reaction_journal_#{journal.id}\"]")
+ assert_reaction_add_and_remove(reaction_button, journal.reload)
+ end
+ end
+
+ def test_react_to_forum_reply
+ log_user('jsmith', 'jsmith')
+
+ reply_message = messages(:messages_002) # reply to message_001
+
+ with_settings(reactions_enabled: '1') do
+ visit 'boards/1/topics/1'
+ reaction_button = find("[data-reaction-button-id=\"reaction_message_#{reply_message.id}\"]")
+ assert_reaction_add_and_remove(reaction_button, reply_message)
+ end
+ end
+
+ def test_react_to_forum_message
+ log_user('jsmith', 'jsmith')
+
+ message = messages(:messages_001)
+
+ with_settings(reactions_enabled: '1') do
+ visit 'boards/1/topics/1'
+ reaction_button = find("[data-reaction-button-id=\"reaction_message_#{message.id}\"]")
+ assert_reaction_add_and_remove(reaction_button, message)
+ end
+ end
+
+ def test_react_to_news
+ log_user('jsmith', 'jsmith')
+
+ with_settings(reactions_enabled: '1') do
+ visit '/news/2'
+ reaction_button = find("[data-reaction-button-id=\"reaction_news_2\"]")
+ assert_reaction_add_and_remove(reaction_button, news(:news_002))
+ end
+ end
+
+ def test_react_to_comment
+ log_user('jsmith', 'jsmith')
+
+ comment = comments(:comments_002)
+
+ with_settings(reactions_enabled: '1') do
+ visit '/news/1'
+ reaction_button = find("[data-reaction-button-id=\"reaction_comment_#{comment.id}\"]")
+ assert_reaction_add_and_remove(reaction_button, comment)
+ end
+ end
+
+ def test_reactions_disabled
+ log_user('jsmith', 'jsmith')
+
+ with_settings(reactions_enabled: '0') do
+ visit '/issues/1'
+ assert_no_selector('[data-reaction-button-id="reaction_issue_1"]')
+ end
+ end
+
+ def test_reaction_button_is_visible_but_not_clickable_for_not_logged_in_user
+ with_settings(reactions_enabled: '1') do
+ visit '/issues/1'
+
+ # visible
+ reaction_button = find('div.issue.details [data-reaction-button-id="reaction_issue_1"]')
+ within(reaction_button) { assert_selector('span.reaction-button') }
+ assert_equal "3", reaction_button.text
+
+ # not clickable
+ within(reaction_button) { assert_no_selector('a.reaction-button') }
+ end
+ end
+
+ private
+
+ def assert_reaction_add_and_remove(reaction_button, expected_subject)
+ # Add a reaction
+ within(reaction_button) { find('a.reaction-button').click }
+ find('body').hover # Hide tooltip
+ within(reaction_button) { assert_selector('a.reaction-button.reacted[title="John Smith"]') }
+ assert_equal "1", reaction_button.text
+ assert_equal 1, expected_subject.reactions.count
+
+ # Remove the reaction
+ within(reaction_button) { find('a.reacted').click }
+ within(reaction_button) { assert_selector('a.reaction-button:not(.reacted)') }
+ assert_equal "0", reaction_button.text
+ assert_equal 0, expected_subject.reactions.count
+ end
+end
diff --git a/test/unit/lib/redmine/reaction_test.rb b/test/unit/lib/redmine/reaction_test.rb
new file mode 100644
index 000000000..bed4600d0
--- /dev/null
+++ b/test/unit/lib/redmine/reaction_test.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+class Redmine::ReactionTest < ActiveSupport::TestCase
+ setup do
+ @user = users(:users_002)
+ @issue = issues(:issues_007)
+ Setting.reactions_enabled = '1'
+ end
+
+ teardown do
+ Setting.clear_cache
+ end
+
+ test 'preload_reaction_details preloads ReactionDetail for all objects in the collection' do
+ User.current = users(:users_002)
+
+ issue1 = issues(:issues_001)
+ issue2 = issues(:issues_002)
+
+ assert_nil issue1.instance_variable_get(:@reaction_detail)
+ assert_nil issue2.instance_variable_get(:@reaction_detail)
+
+ Issue.preload_reaction_details([issue1, issue2])
+
+ expected_issue1_reaction_detail = Reaction::Detail.new(
+ reaction_count: 3,
+ visible_users: [users(:users_003), users(:users_002), users(:users_001)],
+ user_reaction: reactions(:reaction_002)
+ )
+
+ # ReactionDetail is already preloaded, so calling reaction_detail does not execute any query.
+ assert_no_queries do
+ assert_equal expected_issue1_reaction_detail, issue1.reaction_detail
+
+ # Even when an object has no reactions, an empty ReactionDetail is set.
+ assert_equal Reaction::Detail.new(
+ reaction_count: 0,
+ visible_users: [],
+ user_reaction: nil
+ ), issue2.reaction_detail
+ end
+ end
+
+ test 'visible_users in ReactionDetail preloaded by preload_reaction_details does not include non-visible users' do
+ current_user = User.current = User.generate!
+ visible_user = users(:users_002)
+ non_visible_user = User.generate!
+
+ project = Project.generate!
+ role = Role.generate!(users_visibility: 'members_of_visible_projects')
+
+ User.add_to_project(current_user, project, role)
+ User.add_to_project(visible_user, project, roles(:roles_001))
+
+ issue = Issue.generate!(project: project)
+
+ [current_user, visible_user, non_visible_user].each do |user|
+ issue.reactions.create!(user: user)
+ end
+
+ Issue.preload_reaction_details([issue])
+
+ # non_visible_user is not visible to current_user because they do not belong to any project.
+ assert_equal [visible_user, current_user], issue.reaction_detail.visible_users
+ end
+
+ test 'preload_reaction_details does nothing when the reaction feature is disabled' do
+ Setting.reactions_enabled = '0'
+
+ User.current = users(:users_002)
+ news1 = news(:news_001)
+
+ # Stub the Setting to avoid executing queries for retrieving settings,
+ # making it easier to confirm no queries are executed by preload_reaction_details().
+ Setting.stubs(:reactions_enabled?).returns(false)
+
+ assert_no_queries do
+ News.preload_reaction_details([news1])
+ end
+
+ assert_nil news1.instance_variable_get(:@reaction_detail)
+ end
+
+ test 'reaction_detail loads and returns ReactionDetail if it is not preloaded' do
+ message7 = messages(:messages_007)
+
+ User.current = users(:users_002)
+ assert_nil message7.instance_variable_get(:@reaction_detail)
+
+ assert_equal Reaction::Detail.new(
+ reaction_count: 1,
+ visible_users: [users(:users_002)],
+ user_reaction: reactions(:reaction_009)
+ ), message7.reaction_detail
+ end
+
+ test 'load_reaction_detail loads ReactionDetail for the object itself' do
+ comment1 = comments(:comments_001)
+
+ User.current = users(:users_001)
+ assert_nil comment1.instance_variable_get(:@reaction_detail)
+
+ comment1.load_reaction_detail
+
+ assert_equal Reaction::Detail.new(
+ reaction_count: 1,
+ visible_users: [users(:users_002)],
+ user_reaction: nil
+ ), comment1.reaction_detail
+ end
+
+ test 'visible? returns true when reactions are enabled and object is visible to user' do
+ object = issues(:issues_007)
+ user = users(:users_002)
+
+ assert Redmine::Reaction.visible?(object, user)
+ end
+
+ test 'visible? returns false when reactions are disabled' do
+ Setting.reactions_enabled = '0'
+
+ object = issues(:issues_007)
+ user = users(:users_002)
+
+ assert_not Redmine::Reaction.visible?(object, user)
+ end
+
+ test 'visible? returns false when object is not visible to user' do
+ object = issues(:issues_007)
+ user = users(:users_002)
+
+ object.expects(:visible?).with(user).returns(false)
+
+ assert_not Redmine::Reaction.visible?(object, user)
+ end
+
+ test 'writable? returns true for various reactable objects when user is logged in, object is visible, and project is active' do
+ reactable_objects = {
+ issue: issues(:issues_007),
+ message: messages(:messages_001),
+ news: news(:news_001),
+ journal: journals(:journals_001),
+ comment: comments(:comments_002)
+ }
+ user = users(:users_002)
+
+ reactable_objects.each do |type, object|
+ assert Redmine::Reaction.writable?(object, user), "Expected writable? to return true for #{type}"
+ end
+ end
+
+ test 'writable? returns false when user is not logged in' do
+ object = issues(:issues_007)
+ user = User.anonymous
+
+ assert_not Redmine::Reaction.writable?(object, user)
+ end
+
+ test 'writable? returns false when project is inactive' do
+ object = issues(:issues_007)
+ user = users(:users_002)
+ object.project.update!(status: Project::STATUS_ARCHIVED)
+
+ assert_not Redmine::Reaction.writable?(object, user)
+ end
+
+ test 'writable? returns false when project is closed' do
+ object = issues(:issues_007)
+ user = users(:users_002)
+ object.project.update!(status: Project::STATUS_CLOSED)
+
+ assert_not Redmine::Reaction.writable?(object, user)
+ end
+end
diff --git a/test/unit/reaction_test.rb b/test/unit/reaction_test.rb
new file mode 100644
index 000000000..2690da351
--- /dev/null
+++ b/test/unit/reaction_test.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../test_helper'
+
+class ReactionTest < ActiveSupport::TestCase
+ test 'validates :inclusion of reactable_type' do
+ %w(Issue Journal News Comment Message).each do |type|
+ reaction = Reaction.new(reactable_type: type, user: User.new)
+ assert reaction.valid?
+ end
+
+ assert_not Reaction.new(reactable_type: 'InvalidType', user: User.new).valid?
+ end
+
+ test 'scope: by' do
+ user2_reactions = issues(:issues_001).reactions.by(users(:users_002))
+
+ assert_equal [reactions(:reaction_002)], user2_reactions
+ end
+
+ test "should prevent duplicate reactions with unique constraint under concurrent creation" do
+ user = users(:users_001)
+ issue = issues(:issues_004)
+
+ threads = []
+ results = []
+
+ # Ensure both threads start at the same time
+ barrier = Concurrent::CyclicBarrier.new(2)
+
+ # Create two threads to simulate concurrent creation
+ 2.times do
+ threads << Thread.new do
+ barrier.wait # Wait for both threads to be ready
+ begin
+ reaction = Reaction.create(
+ reactable: issue,
+ user: user
+ )
+ results << reaction.persisted?
+ rescue ActiveRecord::RecordNotUnique
+ results << false
+ end
+ end
+ end
+
+ # Wait for both threads to finish
+ threads.each(&:join)
+
+ # Ensure only one reaction was created
+ assert_equal 1, Reaction.where(reactable: issue, user: user).count
+ assert_includes results, true
+ assert_equal 1, results.count(true)
+ end
+
+ test 'build_detail_map_for generates a detail map for reactable objects' do
+ result = Reaction.build_detail_map_for([issues(:issues_001), issues(:issues_006)], users(:users_003))
+
+ expected = {
+ 1 => Reaction::Detail.new(
+ reaction_count: 3,
+ visible_users: [users(:users_003), users(:users_002), users(:users_001)],
+ user_reaction: reactions(:reaction_003)
+ ),
+ 6 => Reaction::Detail.new(
+ reaction_count: 1,
+ visible_users: [users(:users_002)],
+ user_reaction: nil
+ )
+ }
+ assert_equal expected, result
+
+ # When an object have no reactions, the result should be empty.
+ result = Reaction.build_detail_map_for([journals(:journals_002)], users(:users_002))
+
+ assert_empty result
+ end
+
+ test 'build_detail_map_for filters users based on visibility' do
+ current_user = User.generate!
+ visible_user = users(:users_002)
+ non_visible_user = User.generate!
+
+ project = Project.generate!
+ role = Role.generate!(users_visibility: 'members_of_visible_projects')
+
+ User.add_to_project(current_user, project, role)
+ User.add_to_project(visible_user, project, roles(:roles_001))
+
+ issue = Issue.generate!(project: project)
+
+ [current_user, visible_user, non_visible_user].each do |user|
+ issue.reactions.create!(user: user)
+ end
+
+ result = Reaction.build_detail_map_for([issue], current_user)
+
+ assert_equal(
+ [current_user, visible_user].sort_by(&:id),
+ result[issue.id].visible_users.sort_by(&:id)
+ )
+ end
+end
diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb
index ede12e1ce..aeae62df8 100644
--- a/test/unit/user_test.rb
+++ b/test/unit/user_test.rb
@@ -1376,4 +1376,16 @@ class UserTest < ActiveSupport::TestCase
User.prune(7)
end
end
+
+ def test_destroy_should_delete_associated_reactions
+ users(:users_004).reactions.create!(
+ [
+ {reactable: issues(:issues_001)},
+ {reactable: issues(:issues_002)}
+ ]
+ )
+ assert_difference 'Reaction.count', -2 do
+ users(:users_004).destroy
+ end
+ end
end