diff options
author | Go MAEDA <maeda@farend.jp> | 2025-05-11 07:59:16 +0000 |
---|---|---|
committer | Go MAEDA <maeda@farend.jp> | 2025-05-11 07:59:16 +0000 |
commit | 403c10091f05aa88105439e970bb8985f2f78bec (patch) | |
tree | 69b04a6c6780ac4a15b1b7da982537aa220b032b | |
parent | b650804fe902de02b8b1b6758deca642a4cc1c59 (diff) | |
download | redmine-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
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 |