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
This commit is contained in:
Go MAEDA
2025-05-11 07:59:16 +00:00
parent b650804fe9
commit 403c10091f
42 changed files with 1612 additions and 11 deletions

View File

@@ -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"/>

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -22,6 +22,7 @@ module IssuesHelper
include Redmine::Export::PDF::IssuesPdfHelper
include IssueStatusesHelper
include QueriesHelper
include ReactionsHelper
def issue_list(issues, &)
ancestors = []

View File

@@ -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)

View File

@@ -19,4 +19,5 @@
module MessagesHelper
include Redmine::QuoteReply::Helper
include ReactionsHelper
end

View File

@@ -18,4 +18,5 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module NewsHelper
include ReactionsHelper
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

65
app/models/reaction.rb Normal file
View File

@@ -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

View File

@@ -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}")}

View File

@@ -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 %>

View File

@@ -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",

View File

@@ -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),

View File

@@ -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);
})();

View File

@@ -0,0 +1 @@
<%= render 'replace_button' %>

View File

@@ -0,0 +1 @@
<%= render 'replace_button' %>

View File

@@ -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>

View File

@@ -221,4 +221,9 @@
- name: unwatch
svg: eye-off
- name: copy-pre-content
svg: clipboard
svg: clipboard
- name: thumb-up
svg: thumb-up
- name: thumb-up-filled
svg: thumb-up
style: filled

View File

@@ -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"

View File

@@ -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}人"

View File

@@ -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'

View File

@@ -363,3 +363,5 @@ show_status_changes_in_mail_subject:
default: 1
wiki_tablesort_enabled:
default: 1
reactions_enabled:
default: 1

View File

@@ -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

70
lib/redmine/reaction.rb Normal file
View File

@@ -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

51
test/fixtures/reactions.yml vendored Normal file
View File

@@ -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

View File

@@ -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]

View File

@@ -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})

View File

@@ -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})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

120
test/unit/reaction_test.rb Normal file
View File

@@ -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

View File

@@ -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