mirror of
https://github.com/redmine/redmine.git
synced 2025-10-26 00:36:14 +02:00
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:
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
65
app/controllers/reactions_controller.rb
Normal file
65
app/controllers/reactions_controller.rb
Normal 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
|
||||
@@ -22,6 +22,7 @@ module IssuesHelper
|
||||
include Redmine::Export::PDF::IssuesPdfHelper
|
||||
include IssueStatusesHelper
|
||||
include QueriesHelper
|
||||
include ReactionsHelper
|
||||
|
||||
def issue_list(issues, &)
|
||||
ancestors = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,4 +19,5 @@
|
||||
|
||||
module MessagesHelper
|
||||
include Redmine::QuoteReply::Helper
|
||||
include ReactionsHelper
|
||||
end
|
||||
|
||||
@@ -18,4 +18,5 @@
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module NewsHelper
|
||||
include ReactionsHelper
|
||||
end
|
||||
|
||||
100
app/helpers/reactions_helper.rb
Normal file
100
app/helpers/reactions_helper.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
65
app/models/reaction.rb
Normal 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
|
||||
@@ -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}")}
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
7
app/views/reactions/_replace_button.js.erb
Normal file
7
app/views/reactions/_replace_button.js.erb
Normal 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);
|
||||
})();
|
||||
1
app/views/reactions/create.js.erb
Normal file
1
app/views/reactions/create.js.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render 'replace_button' %>
|
||||
1
app/views/reactions/destroy.js.erb
Normal file
1
app/views/reactions/destroy.js.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render 'replace_button' %>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}人"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -363,3 +363,5 @@ show_status_changes_in_mail_subject:
|
||||
default: 1
|
||||
wiki_tablesort_enabled:
|
||||
default: 1
|
||||
reactions_enabled:
|
||||
default: 1
|
||||
|
||||
11
db/migrate/20250423065135_create_reactions.rb
Normal file
11
db/migrate/20250423065135_create_reactions.rb
Normal 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
70
lib/redmine/reaction.rb
Normal 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
51
test/fixtures/reactions.yml
vendored
Normal 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
|
||||
@@ -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]
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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})
|
||||
|
||||
394
test/functional/reactions_controller_test.rb
Normal file
394
test/functional/reactions_controller_test.rb
Normal 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
|
||||
196
test/helpers/reactions_helper_test.rb
Normal file
196
test/helpers/reactions_helper_test.rb
Normal 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
|
||||
132
test/system/reactions_test.rb
Normal file
132
test/system/reactions_test.rb
Normal 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
|
||||
193
test/unit/lib/redmine/reaction_test.rb
Normal file
193
test/unit/lib/redmine/reaction_test.rb
Normal 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
120
test/unit/reaction_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user