diff --git a/app/components/_index.sass b/app/components/_index.sass index 99b924ffaef7..2c0471a10423 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -3,6 +3,7 @@ @import "work_packages/activities_tab/journals/index_component" @import "work_packages/activities_tab/journals/item_component" @import "work_packages/activities_tab/journals/item_component/details" +@import "work_packages/activities_tab/journals/item_component/reactions" @import "shares/modal_body_component" @import "shares/invite_user_form_component" @import "work_packages/details/tab_component" diff --git a/app/components/work_packages/activities_tab/journals/index_component.html.erb b/app/components/work_packages/activities_tab/journals/index_component.html.erb index d6aa9ddc56c8..df3573e39ee3 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/index_component.html.erb @@ -1,25 +1,35 @@ <%= component_wrapper(class: "work-packages-activities-tab-journals-index-component") do flex_layout do |journals_index_wrapper_container| - journals_index_wrapper_container.with_row(classes: "work-packages-activities-tab-journals-index-component--journals-inner-container", mb: inner_container_margin_bottom) do - flex_layout(id: insert_target_modifier_id, data: { "test_selector": "op-wp-journals-container" }) do |journals_index_container| + journals_index_wrapper_container.with_row( + classes: "work-packages-activities-tab-journals-index-component--journals-inner-container", + mb: inner_container_margin_bottom + ) do + flex_layout(id: insert_target_modifier_id, + data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| if empty_state? journals_index_container.with_row(mt: 2, mb: 3) do render( - WorkPackages::ActivitiesTab::Journals::EmptyComponent.new() + WorkPackages::ActivitiesTab::Journals::EmptyComponent.new ) end end + journals.each do |journal| journals_index_container.with_row do - render( - WorkPackages::ActivitiesTab::Journals::ItemComponent.new(journal:, filter:) - ) + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal:, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] + )) end end end end - journals_index_wrapper_container.with_row(classes: "work-packages-activities-tab-journals-index-component--stem-connection") unless empty_state? || journal_sorting == "desc" + + unless empty_state? || journal_sorting_desc? + journals_index_wrapper_container + .with_row(classes: "work-packages-activities-tab-journals-index-component--stem-connection") + end end end %> diff --git a/app/components/work_packages/activities_tab/journals/index_component.rb b/app/components/work_packages/activities_tab/journals/index_component.rb index 3e503df4334b..582432c9440d 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.rb +++ b/app/components/work_packages/activities_tab/journals/index_component.rb @@ -59,6 +59,10 @@ def journal_sorting User.current.preference&.comments_sorting || "desc" end + def journal_sorting_desc? + journal_sorting == "desc" + end + def journals work_package.journals.includes(:user, :notifications).reorder(version: journal_sorting) end @@ -67,12 +71,16 @@ def journal_with_notes journals.where.not(notes: "") end + def wp_journals_grouped_emoji_reactions + @wp_journals_grouped_emoji_reactions ||= Journal.grouped_work_package_journals_emoji_reactions(work_package) + end + def empty_state? filter == :only_comments && journal_with_notes.empty? end def inner_container_margin_bottom - if journal_sorting == "desc" + if journal_sorting_desc? 3 else 0 diff --git a/app/components/work_packages/activities_tab/journals/item_component.html.erb b/app/components/work_packages/activities_tab/journals/item_component.html.erb index 9419133d8029..0e87fad9783d 100644 --- a/app/components/work_packages/activities_tab/journals/item_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/item_component.html.erb @@ -1,28 +1,31 @@ <%= component_wrapper(data: wrapper_data_attributes, class: "work-packages-activities-tab-journals-item-component") do - flex_layout(data: { "test_selector": "op-wp-journal-entry-#{journal.id}" }) do |journal_container| + flex_layout(data: { test_selector: "op-wp-journal-entry-#{journal.id}" }) do |journal_container| if show_comment_container? journal_container.with_row do render(border_box_container( - id: "activity-anchor-#{journal.version}", - padding: :condensed, - "aria-label": I18n.t("activities.work_packages.activity_tab.commented") - )) do |border_box_component| + id: "activity-anchor-#{journal.version}", + padding: :condensed, + "aria-label": I18n.t("activities.work_packages.activity_tab.commented") + )) do |border_box_component| border_box_component.with_header(px: 2, py: 1, data: { test_selector: "op-journal-notes-header" }) do flex_layout(align_items: :center, justify_content: :space_between) do |header_container| - header_container.with_column(flex_layout: true, classes: "work-packages-activities-tab-journals-item-component--header-start-container ellipsis") do |header_start_container| + header_container.with_column(flex_layout: true, + classes: "work-packages-activities-tab-journals-item-component--header-start-container ellipsis") do |header_start_container| header_start_container.with_column(mr: 2) do render Users::AvatarComponent.new(user: journal.user, show_name: false, size: :mini) end - header_start_container.with_column(mr: 1, flex_layout: true, classes: "work-packages-activities-tab-journals-item-component--user-name-container hidden-for-desktop") do |user_name_container| + header_start_container.with_column(mr: 1, flex_layout: true, + classes: "work-packages-activities-tab-journals-item-component--user-name-container hidden-for-desktop") do |user_name_container| user_name_container.with_row(classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis") do truncated_user_name(journal.user) end user_name_container.with_row do render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mt: 1)) { format_time(journal.created_at) } - end + end end - header_start_container.with_column(mr: 1, classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis hidden-for-mobile") do + header_start_container.with_column(mr: 1, + classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis hidden-for-mobile") do truncated_user_name(journal.user) end header_start_container.with_column(mr: 1, classes: "hidden-for-mobile") do @@ -33,32 +36,33 @@ if has_unread_notifications? header_end_container.with_column(mr: 2, pt: 1) do render(Primer::Beta::Octicon.new( - :"dot-fill", # color is set via CSS as requested by UI/UX Team - classes: "work-packages-activities-tab-journals-item-component--notification-dot-icon", - size: :medium, - data: { test_selector: "op-journal-unread-notification", "op-ian-center-update-immediate": true } - )) + :"dot-fill", # color is set via CSS as requested by UI/UX Team + classes: "work-packages-activities-tab-journals-item-component--notification-dot-icon", + size: :medium, + data: { test_selector: "op-journal-unread-notification", "op-ian-center-update-immediate": true } + )) end end header_end_container.with_column do render(Primer::Beta::Link.new( - href: "#", - scheme: :secondary, - underline: false, - font_size: :small, - data: { - turbo: false, - action: "click->work-packages--activities-tab--index#setAnchor:prevent", - "work-packages--activities-tab--index-id-param": journal.version - } - )) do + href: "#", + scheme: :secondary, + underline: false, + font_size: :small, + data: { + turbo: false, + action: "click->work-packages--activities-tab--index#setAnchor:prevent", + "work-packages--activities-tab--index-id-param": journal.version + } + )) do "##{journal.version}" end end - header_end_container.with_column(ml: 1, classes: "work-packages-activities-tab-journals-item-component--action-menu") do - render(Primer::Alpha::ActionMenu.new(data: { "test_selector": "op-wp-journal-#{journal.id}-action-menu" })) do |menu| + header_end_container.with_column(ml: 1, + classes: "work-packages-activities-tab-journals-item-component--action-menu") do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "op-wp-journal-#{journal.id}-action-menu" })) do |menu| menu.with_show_button(icon: "kebab-horizontal", - 'aria-label': I18n.t(:button_actions), + "aria-label": I18n.t(:button_actions), scheme: :invisible) copy_url_action_item(menu) edit_action_item(menu) if allowed_to_edit? @@ -70,24 +74,28 @@ end border_box_component.with_body( classes: "work-packages-activities-tab-journals-item-component--journal-notes-body", - data: { "test_selector": "op-journal-notes-body" } + data: { test_selector: "op-journal-notes-body" } ) do - unless noop? + if noop? + render(Primer::Beta::Text.new(font_style: :italic, color: :subtle, mt: 1)) do + I18n.t(:"journals.changes_retracted") + end + else case state when :show - render(WorkPackages::ActivitiesTab::Journals::ItemComponent::Show.new(journal:, filter:)) + render(WorkPackages::ActivitiesTab::Journals::ItemComponent::Show.new(journal:, filter:, + grouped_emoji_reactions:)) when :edit render(WorkPackages::ActivitiesTab::Journals::ItemComponent::Edit.new(journal:, filter:)) end - else - render(Primer::Beta::Text.new(font_style: :italic, color: :subtle, mt: 1)) { I18n.t(:"journals.changes_retracted") } end end end end end journal_container.with_row do - render(WorkPackages::ActivitiesTab::Journals::ItemComponent::Details.new(journal:, has_unread_notifications: notification_on_details?, filter:)) + render(WorkPackages::ActivitiesTab::Journals::ItemComponent::Details.new(journal:, + has_unread_notifications: notification_on_details?, filter:)) end end end diff --git a/app/components/work_packages/activities_tab/journals/item_component.rb b/app/components/work_packages/activities_tab/journals/item_component.rb index e79ecc2c7662..1d20546545bc 100644 --- a/app/components/work_packages/activities_tab/journals/item_component.rb +++ b/app/components/work_packages/activities_tab/journals/item_component.rb @@ -35,17 +35,18 @@ class ItemComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(journal:, filter:, state: :show) + def initialize(journal:, filter:, grouped_emoji_reactions:, state: :show) super @journal = journal - @state = state @filter = filter + @grouped_emoji_reactions = grouped_emoji_reactions + @state = state end private - attr_reader :journal, :state, :filter + attr_reader :journal, :state, :filter, :grouped_emoji_reactions def wrapper_uniq_by journal.id diff --git a/app/components/work_packages/activities_tab/journals/item_component/add_reactions.html.erb b/app/components/work_packages/activities_tab/journals/item_component/add_reactions.html.erb new file mode 100644 index 000000000000..2860b93aaba9 --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/item_component/add_reactions.html.erb @@ -0,0 +1,39 @@ +<%= + component_wrapper do + if journal.available_emoji_reactions.any? + render(Primer::Alpha::Overlay.new( + title: I18n.t("reactions.action_title"), + padding: :condensed, + anchor_side: :outside_top, + visually_hide_title: true + )) do |d| + d.with_show_button( + icon: "smiley", + "aria-label": I18n.t("reactions.add_reaction"), + title: I18n.t("reactions.add_reaction"), + mr: 2, + test_selector: "add-reactions-button" + ) + + d.with_body(pt: 2) do + flex_layout do |add_reactions_container| + journal.available_emoji_reactions.each do |emoji, reaction| + add_reactions_container.with_column(mr: 2) do + render(Primer::Beta::Button.new( + scheme: :invisible, + id: "#{journal.id}-#{reaction}", + tag: :a, + href: toggle_reaction_work_package_activity_path(journal.journable.id, id: journal.id, reaction:), + data: { "turbo-stream": true, "turbo-method": :put }, + "aria-label": I18n.t("reactions.react_with", reaction: reaction.tr("_", "")) + )) do + emoji + end + end + end + end + end + end + end + end +%> diff --git a/app/components/work_packages/activities_tab/journals/item_component/add_reactions.rb b/app/components/work_packages/activities_tab/journals/item_component/add_reactions.rb new file mode 100644 index 000000000000..17bf05d501e0 --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/item_component/add_reactions.rb @@ -0,0 +1,56 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module ActivitiesTab + module Journals + class ItemComponent::AddReactions < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(journal:) + super + + @journal = journal + end + + def render? + User.current.allowed_in_work_package?(:add_work_package_notes, work_package) + end + + private + + attr_reader :journal + + def work_package = journal.journable + def wrapper_uniq_by = journal.id + end + end + end +end diff --git a/app/components/work_packages/activities_tab/journals/item_component/reactions.html.erb b/app/components/work_packages/activities_tab/journals/item_component/reactions.html.erb new file mode 100644 index 000000000000..cafca01cc3b0 --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/item_component/reactions.html.erb @@ -0,0 +1,29 @@ +<%= + component_wrapper do + flex_layout(test_selector: "emoji-reactions") do |reactions_container| + grouped_emoji_reactions.each do |reaction, data| + reactions_container.with_column(mr: 2) do + render(Primer::Beta::Button.new( + scheme: button_scheme(data[:users]), + color: :default, + bg: counter_color(data[:users]), + id: "#{journal.id}-#{reaction}", + test_selector: "reaction-#{reaction}", + tag: :a, + href: href(reaction:), + data: { turbo_stream: true, turbo_method: :put }, + aria: { label: aria_label_text(reaction, data[:users]) }, + disabled: current_user_cannot_react?, + classes: "op-reactions-button" + )) do |button| + button.with_tooltip(text: number_of_user_reactions_text(data[:users]), + test_selector: "reaction-tooltip-#{reaction}") do + button.with_icon(EmojiReactions.emoji(reaction), size: :small) + end + "#{EmojiReaction.emoji(reaction)} #{data[:count]}" + end + end + end + end + end +%> diff --git a/app/components/work_packages/activities_tab/journals/item_component/reactions.rb b/app/components/work_packages/activities_tab/journals/item_component/reactions.rb new file mode 100644 index 000000000000..125c610d07df --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/item_component/reactions.rb @@ -0,0 +1,103 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module ActivitiesTab + module Journals + class ItemComponent::Reactions < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(journal:, grouped_emoji_reactions:) + super + + @journal = journal + @grouped_emoji_reactions = grouped_emoji_reactions + end + + def render? + grouped_emoji_reactions.present? + end + + private + + attr_reader :journal, :grouped_emoji_reactions + + def wrapper_uniq_by = journal.id + def work_package = journal.journable + + def counter_color(users) + reacted_by_current_user?(users) ? :accent : nil + end + + def button_scheme(users) + reacted_by_current_user?(users) ? :default : :invisible + end + + def reacted_by_current_user?(users) + users.any? { |u| u[:id] == User.current.id } + end + + # ARIA-label, show names and emoji type: "{Name of reaction} by {user A}, {user B} and {user C}". + def aria_label_text(reaction, users) + "#{I18n.t('reactions.reaction_by', reaction: reaction.to_s.tr('_', ' '))} #{number_of_user_reactions_text(users)}" + end + + # Visually, show just names: "{user A}, {user B} and {user C}" + def number_of_user_reactions_text(users, max_displayed_users_count: 5) + user_count = users.length + displayed_users = users.take(max_displayed_users_count).pluck(:name) + + return displayed_users.first if user_count == 1 + + if user_count <= max_displayed_users_count + "#{displayed_users[0..-2].join(', ')} #{I18n.t('reactions.and_user', user: displayed_users.last)}" + elsif user_count == max_displayed_users_count + 1 + "#{displayed_users.join(', ')} #{I18n.t('reactions.and_n_others_singular', n: 1)}" + else + "#{displayed_users.join(', ')} #{I18n.t('reactions.and_n_others_plural', + n: user_count - max_displayed_users_count)}" + end + end + + def href(reaction:) + return if current_user_cannot_react? + + toggle_reaction_work_package_activity_path(journal.journable.id, id: journal.id, reaction:) + end + + def current_user_can_react? + User.current.allowed_in_work_package?(:add_work_package_notes, work_package) + end + + def current_user_cannot_react? = !current_user_can_react? + end + end + end +end diff --git a/app/components/work_packages/activities_tab/journals/item_component/reactions.sass b/app/components/work_packages/activities_tab/journals/item_component/reactions.sass new file mode 100644 index 000000000000..e0583044c2ad --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/item_component/reactions.sass @@ -0,0 +1,3 @@ +.op-reactions-button:disabled + cursor: default + background-color: transparent diff --git a/app/components/work_packages/activities_tab/journals/item_component/show.html.erb b/app/components/work_packages/activities_tab/journals/item_component/show.html.erb index 4a71f71166e0..0e7832c5ed88 100644 --- a/app/components/work_packages/activities_tab/journals/item_component/show.html.erb +++ b/app/components/work_packages/activities_tab/journals/item_component/show.html.erb @@ -7,6 +7,14 @@ format_text(journal, :notes) end end + journal_container.with_row(mt: 3, flex_layout: true) do |reactions_container| + reactions_container.with_column do + render(WorkPackages::ActivitiesTab::Journals::ItemComponent::AddReactions.new(journal:)) + end + reactions_container.with_column do + render(WorkPackages::ActivitiesTab::Journals::ItemComponent::Reactions.new(journal:, grouped_emoji_reactions:)) + end + end end end end diff --git a/app/components/work_packages/activities_tab/journals/item_component/show.rb b/app/components/work_packages/activities_tab/journals/item_component/show.rb index e3abc2222065..038ae2576256 100644 --- a/app/components/work_packages/activities_tab/journals/item_component/show.rb +++ b/app/components/work_packages/activities_tab/journals/item_component/show.rb @@ -36,16 +36,17 @@ class ItemComponent::Show < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(journal:, filter:) + def initialize(journal:, filter:, grouped_emoji_reactions:) super @journal = journal @filter = filter + @grouped_emoji_reactions = grouped_emoji_reactions end private - attr_reader :journal, :filter + attr_reader :journal, :filter, :grouped_emoji_reactions def wrapper_uniq_by journal.id diff --git a/app/contracts/emoji_reactions/base_contract.rb b/app/contracts/emoji_reactions/base_contract.rb new file mode 100644 index 000000000000..6474099506a8 --- /dev/null +++ b/app/contracts/emoji_reactions/base_contract.rb @@ -0,0 +1,74 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmojiReactions + class BaseContract < ::ModelContract + attribute :reaction + attribute :user_id + attribute :reactable_id + attribute :reactable_type + + validate :manage_emoji_reactions_permission? + validate :validate_user_exists + validate :validate_acting_user + validate :validate_reactable_exists + + def self.model = EmojiReaction + + private + + def validate_user_exists + errors.add :user, :error_not_found unless User.exists?(model.user_id) + end + + def validate_acting_user + errors.add :user, :error_unauthorized unless model.user_id == user.id + end + + def validate_reactable_exists + errors.add :reactable, :error_not_found if model.reactable.blank? + end + + def manage_emoji_reactions_permission? + unless manage_emoji_reactions? + errors.add :base, :error_unauthorized + end + end + + def manage_emoji_reactions? + case model.reactable + when WorkPackage + user.allowed_in_work_package?(:add_work_package_notes, model.reactable) + when Journal + user.allowed_in_work_package?(:add_work_package_notes, model.reactable.journable) + else + false + end + end + end +end diff --git a/app/contracts/emoji_reactions/create_contract.rb b/app/contracts/emoji_reactions/create_contract.rb new file mode 100644 index 000000000000..b3cf59f48a71 --- /dev/null +++ b/app/contracts/emoji_reactions/create_contract.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmojiReactions + class CreateContract < BaseContract + end +end diff --git a/app/contracts/emoji_reactions/delete_contract.rb b/app/contracts/emoji_reactions/delete_contract.rb new file mode 100644 index 000000000000..385fe7bb5b26 --- /dev/null +++ b/app/contracts/emoji_reactions/delete_contract.rb @@ -0,0 +1,36 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmojiReactions + class DeleteContract < ::DeleteContract + delete_permission -> { + # The user can delete the reaction if they created it + model.user_id == user.id + } + end +end diff --git a/app/controllers/work_packages/activities_tab_controller.rb b/app/controllers/work_packages/activities_tab_controller.rb index 48929162060c..6752478e0ce4 100644 --- a/app/controllers/work_packages/activities_tab_controller.rb +++ b/app/controllers/work_packages/activities_tab_controller.rb @@ -30,10 +30,11 @@ class WorkPackages::ActivitiesTabController < ApplicationController include OpTurbo::ComponentStream + include FlashMessagesOutputSafetyHelper before_action :find_work_package before_action :find_project - before_action :find_journal, only: %i[edit cancel_edit update] + before_action :find_journal, only: %i[edit cancel_edit update toggle_reaction] before_action :set_filter before_action :authorize @@ -48,11 +49,7 @@ def index end def update_streams - if params[:last_update_timestamp].present? - generate_time_based_update_streams(params[:last_update_timestamp]) - else - @turbo_status = :bad_request - end + perform_update_streams_from_last_update_timestamp respond_with_turbo_streams end @@ -96,7 +93,7 @@ def update_sorting def edit if allowed_to_edit?(@journal) - update_item_component(journal: @journal, state: :edit) + update_item_edit_component(journal: @journal) else @turbo_status = :forbidden end @@ -106,7 +103,7 @@ def edit def cancel_edit if allowed_to_edit?(@journal) - update_item_component(journal: @journal, state: :show) + update_item_show_component(journal: @journal, grouped_emoji_reactions: grouped_emoji_reactions_for_journal) else @turbo_status = :forbidden end @@ -133,7 +130,7 @@ def update ) if call.success? && call.result - update_item_component(journal: call.result, state: :show) + update_item_show_component(journal: call.result, grouped_emoji_reactions: grouped_emoji_reactions_for_journal) else handle_failed_update_call(call) end @@ -141,6 +138,38 @@ def update respond_with_turbo_streams end + def toggle_reaction # rubocop:disable Metrics/AbcSize + emoji_reaction_service = + if @journal.emoji_reactions.exists?(user: User.current, reaction: params[:reaction]) + EmojiReactions::DeleteService + .new(user: User.current, + model: @journal.emoji_reactions.find_by(user: User.current, reaction: params[:reaction])) + .call + else + EmojiReactions::CreateService + .new(user: User.current) + .call(user: User.current, reactable: @journal, reaction: params[:reaction]) + end + + emoji_reaction_service.on_success do + update_via_turbo_stream( + component: WorkPackages::ActivitiesTab::Journals::ItemComponent::Show.new( + journal: @journal, + filter: params[:filter]&.to_sym || :all, + grouped_emoji_reactions: grouped_emoji_reactions_for_journal + ) + ) + end + + emoji_reaction_service.on_failure do + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(emoji_reaction_service.errors.full_messages) + ) + end + + respond_with_turbo_streams + end + private def respond_with_error(error_message) @@ -220,7 +249,16 @@ def handle_other_filters_on_create(call) if call.result.initial? update_index_component # update the whole index component to reset empty state else - generate_time_based_update_streams(params[:last_update_timestamp]) + perform_update_streams_from_last_update_timestamp + end + end + + def perform_update_streams_from_last_update_timestamp + if params[:last_update_timestamp].present? && (last_updated_at = Time.zone.parse(params[:last_update_timestamp])) + generate_time_based_update_streams(last_updated_at) + generate_time_based_reaction_update_streams(last_updated_at) + else + @turbo_status = :bad_request end end @@ -270,16 +308,6 @@ def create_journal_service_call ### end - def update_item_component(journal:, filter: @filter, state: :show) - update_via_turbo_stream( - component: WorkPackages::ActivitiesTab::Journals::ItemComponent.new( - journal:, - state:, - filter: - ) - ) - end - def generate_time_based_update_streams(last_update_timestamp) journals = @work_package.journals @@ -287,47 +315,71 @@ def generate_time_based_update_streams(last_update_timestamp) journals = journals.where.not(notes: "") end - rerender_updated_journals(journals, last_update_timestamp) - - rerender_journals_with_updated_notification(journals, last_update_timestamp) + grouped_emoji_reactions = + if journals.present? + Journal.grouped_emoji_reactions_by_reactable( + reactable_id: journals.pluck(:id), + reactable_type: "Journal", last_updated_at: last_update_timestamp + ) + else + {} + end - append_or_prepend_journals(journals, last_update_timestamp) + rerender_updated_journals(journals, last_update_timestamp, grouped_emoji_reactions) + rerender_journals_with_updated_notification(journals, last_update_timestamp, grouped_emoji_reactions) + append_or_prepend_journals(journals, last_update_timestamp, grouped_emoji_reactions) - if journals.any? + if journals.present? remove_potential_empty_state update_activity_counter end end - def rerender_updated_journals(journals, last_update_timestamp) + def generate_time_based_reaction_update_streams(last_updated_at) + # Current limitation: Only shows added reactions, not removed ones + Journal.grouped_work_package_journals_emoji_reactions( + @work_package, last_updated_at: + ).each do |journal_id, grouped_emoji_reactions| + update_via_turbo_stream( + component: WorkPackages::ActivitiesTab::Journals::ItemComponent::Reactions.new( + journal: @work_package.journals.find(journal_id), + grouped_emoji_reactions: + ) + ) + end + end + + def rerender_updated_journals(journals, last_update_timestamp, grouped_emoji_reactions) journals.where("updated_at > ?", last_update_timestamp).find_each do |journal| - update_item_component(journal:) + update_item_show_component(journal:, grouped_emoji_reactions: grouped_emoji_reactions.fetch(journal.id, {})) end end - def rerender_journals_with_updated_notification(journals, last_update_timestamp) + def rerender_journals_with_updated_notification(journals, last_update_timestamp, grouped_emoji_reactions) # Case: the user marked the journal as read somewhere else and expects the bubble to disappear journals .joins(:notifications) .where("notifications.updated_at > ?", last_update_timestamp) .find_each do |journal| - update_item_component(journal:) + update_item_show_component(journal:, grouped_emoji_reactions: grouped_emoji_reactions.fetch(journal.id, {})) end end - def append_or_prepend_journals(journals, last_update_timestamp) + def append_or_prepend_journals(journals, last_update_timestamp, grouped_emoji_reactions) journals.where("created_at > ?", last_update_timestamp).find_each do |journal| - append_or_prepend_latest_journal_via_turbo_stream(journal) + append_or_prepend_latest_journal_via_turbo_stream(journal, grouped_emoji_reactions.fetch(journal.id, {})) end end - def append_or_prepend_latest_journal_via_turbo_stream(journal) + def append_or_prepend_latest_journal_via_turbo_stream(journal, grouped_emoji_reactions) target_component = WorkPackages::ActivitiesTab::Journals::IndexComponent.new( work_package: @work_package, filter: @filter ) - component = WorkPackages::ActivitiesTab::Journals::ItemComponent.new(journal:, filter: @filter) + component = WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal:, filter: @filter, grouped_emoji_reactions: + ) stream_config = { target_component:, @@ -349,6 +401,25 @@ def remove_potential_empty_state ) end + def update_item_edit_component(journal:, grouped_emoji_reactions: {}) + update_item_component(journal:, state: :edit, grouped_emoji_reactions:) + end + + def update_item_show_component(journal:, grouped_emoji_reactions:) + update_item_component(journal:, state: :show, grouped_emoji_reactions:) + end + + def update_item_component(journal:, grouped_emoji_reactions:, state:, filter: @filter) + update_via_turbo_stream( + component: WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal:, + state:, + filter:, + grouped_emoji_reactions: + ) + ) + end + def update_activity_counter # update the activity counter in the primerized tabs # not targeting the legacy tab! @@ -357,6 +428,10 @@ def update_activity_counter ) end + def grouped_emoji_reactions_for_journal + Journal.grouped_journal_emoji_reactions(@journal).fetch(@journal.id, {}) + end + def allowed_to_edit?(journal) journal.editable_by?(User.current) end diff --git a/app/models/concerns/reactable.rb b/app/models/concerns/reactable.rb new file mode 100644 index 000000000000..855634d9cfed --- /dev/null +++ b/app/models/concerns/reactable.rb @@ -0,0 +1,94 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reactable + extend ActiveSupport::Concern + + included do + has_many :emoji_reactions, as: :reactable, dependent: :destroy + end + + class_methods do + def grouped_journal_emoji_reactions(journal) + grouped_emoji_reactions_by_reactable(reactable_id: journal.id, reactable_type: "Journal") + end + + def grouped_work_package_journals_emoji_reactions(work_package, last_updated_at: nil) + grouped_emoji_reactions_by_reactable(reactable_id: work_package.journal_ids, reactable_type: "Journal", last_updated_at:) + end + + def grouped_emoji_reactions_by_reactable(reactable_id:, reactable_type:, last_updated_at: nil) + grouped_emoji_reactions(reactable_id:, reactable_type:, last_updated_at:).each_with_object({}) do |row, hash| + hash[row.reactable_id] ||= {} + hash[row.reactable_id][row.reaction.to_sym] = { + count: row.count, + users: row.user_details.map { |(id, name)| { id:, name: } } + } + end + end + + def grouped_emoji_reactions(reactable_id:, reactable_type:, last_updated_at: nil) + query = EmojiReaction + .select("emoji_reactions.reactable_id, emoji_reactions.reaction, COUNT(emoji_reactions.id) as count, " \ + "json_agg(json_build_array(users.id, #{user_name_concat_format_sql}) ORDER BY emoji_reactions.created_at) as user_details") # rubocop:disable Layout/LineLength + .joins(:user) + .where(reactable_id:, reactable_type:) + + query = query.where("emoji_reactions.updated_at > ?", last_updated_at) if last_updated_at + + query.group("emoji_reactions.reactable_id, emoji_reactions.reaction") + end + + private + + def user_name_concat_format_sql + case Setting.user_format + when :firstname_lastname + "concat_ws(' ', users.firstname, users.lastname)" + when :firstname + "users.firstname" + when :lastname_firstname + "concat_ws(' ', users.lastname, users.firstname)" + when :lastname_coma_firstname + "concat_ws(', ', users.lastname, users.firstname)" + when :lastname_n_firstname + "concat_ws('', users.lastname, users.firstname)" + when :username + "users.login" + else + raise ArgumentError, "Unsupported user format: #{Setting.user_format}" + end + end + end + + def available_emoji_reactions + (EmojiReaction.reactions.values - emoji_reactions.pluck(:reaction).uniq).index_by do |reaction| + EmojiReaction.emoji(reaction) + end.sort + end +end diff --git a/app/models/emoji_reaction.rb b/app/models/emoji_reaction.rb new file mode 100644 index 000000000000..1c7e86515925 --- /dev/null +++ b/app/models/emoji_reaction.rb @@ -0,0 +1,59 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class EmojiReaction < ApplicationRecord + # See: https://unicode.org/Public/emoji/latest/emoji-test.txt + EMOJI_MAP = { + thumbs_up: "\u{1F44D}", + thumbs_down: "\u{1F44E}", + grinning_face_with_smiling_eyes: "\u{1F604}", + confused_face: "\u{1F615}", + heart: "\u{2764}", + party_popper: "\u{1F389}", + rocket: "\u{1F680}", + eyes: "\u{1F440}" + }.freeze + + AVAILABLE_EMOJIS = EMOJI_MAP.values.freeze + + belongs_to :user + belongs_to :reactable, polymorphic: true + + validates :reaction, presence: true + validates :user_id, uniqueness: { scope: %i[reactable_type reactable_id reaction] } + + enum :reaction, EMOJI_MAP.each_with_object({}) { |(k, _v), h| h[k] = k.to_s } + + def self.available_emojis + AVAILABLE_EMOJIS + end + + def self.emoji(reaction) + EMOJI_MAP[reaction.to_sym] + end +end diff --git a/app/models/journal.rb b/app/models/journal.rb index 5c372671a6fd..cbb216b1eaa4 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -34,6 +34,7 @@ class Journal < ApplicationRecord include ::JournalFormatter include ::Acts::Journalized::FormatHooks include Journal::Timestamps + include Reactable register_journal_formatter OpenProject::JournalFormatter::ActiveStatus register_journal_formatter OpenProject::JournalFormatter::AgendaItemDiff diff --git a/app/services/emoji_reactions/create_service.rb b/app/services/emoji_reactions/create_service.rb new file mode 100644 index 000000000000..5fd2f0a683b0 --- /dev/null +++ b/app/services/emoji_reactions/create_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmojiReactions + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/emoji_reactions/delete_service.rb b/app/services/emoji_reactions/delete_service.rb new file mode 100644 index 000000000000..aa4d5c63fa7a --- /dev/null +++ b/app/services/emoji_reactions/delete_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmojiReactions + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/emoji_reactions/set_attributes_service.rb b/app/services/emoji_reactions/set_attributes_service.rb new file mode 100644 index 000000000000..d37f853468eb --- /dev/null +++ b/app/services/emoji_reactions/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmojiReactions + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index f601cd45e766..91a8810c9240 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -262,7 +262,7 @@ # FIXME: Although the endpoint is removed, the code checking whether a user # is eligible to add work packages through the API still seems to rely on this. journals: [:new], - "work_packages/activities_tab": %i[create] + "work_packages/activities_tab": %i[create toggle_reaction] }, permissible_on: %i[work_package project], dependencies: :view_work_packages diff --git a/config/locales/en.yml b/config/locales/en.yml index 22346a469f6f..33cd71880774 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -546,6 +546,15 @@ en: Click to assign or change the color of this priority. It can be used for highlighting work packages in the table. + reactions: + action_title: "React" + add_reaction: "Add reaction" + react_with: "React with %{reaction}" + and_user: "and %{user}" + and_n_others_singular: "and %{n} other" + and_n_others_plural: "and %{n} others" + reaction_by: "%{reaction} by" + reportings: index: no_results_title_text: There are currently no status reportings. @@ -934,6 +943,7 @@ en: could_not_be_copied: "%{dependency} could not be (fully) copied." does_not_exist: "does not exist." error_enterprise_only: "%{action} is only available in the OpenProject Enterprise edition" + error_not_found: "not found." error_unauthorized: "may not be accessed." error_readonly: "was attempted to be written but is not writable." error_conflict: "Information has been updated by at least one other user in the meantime." diff --git a/config/routes.rb b/config/routes.rb index eff38e6da7ad..9ed538338cc2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -587,6 +587,7 @@ resources :activities, controller: "work_packages/activities_tab", only: %i[index create edit update] do member do get :cancel_edit + put :toggle_reaction end collection do get :update_streams diff --git a/db/migrate/20240624103354_create_emoji_reactions.rb b/db/migrate/20240624103354_create_emoji_reactions.rb new file mode 100644 index 000000000000..0e3ab38496c6 --- /dev/null +++ b/db/migrate/20240624103354_create_emoji_reactions.rb @@ -0,0 +1,15 @@ +class CreateEmojiReactions < ActiveRecord::Migration[7.1] + def change + create_table :emoji_reactions do |t| + t.references :user, null: false, foreign_key: true + t.references :reactable, polymorphic: true, null: false + t.string :reaction, null: false + t.timestamps + end + + add_index :emoji_reactions, :reaction + add_index :emoji_reactions, %i[user_id reactable_type reactable_id reaction], + unique: true, + name: "index_emoji_reactions_uniqueness" + end +end diff --git a/spec/components/work_packages/activities_tab/journals/item_component/reactions_spec.rb b/spec/components/work_packages/activities_tab/journals/item_component/reactions_spec.rb new file mode 100644 index 000000000000..6c39bc5cdb08 --- /dev/null +++ b/spec/components/work_packages/activities_tab/journals/item_component/reactions_spec.rb @@ -0,0 +1,88 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +# +require "spec_helper" + +RSpec.describe WorkPackages::ActivitiesTab::Journals::ItemComponent::Reactions, type: :component do + let(:journal) { build_stubbed(:work_package_journal) } + + context "with reactions" do + it "renders the reactions" do + render_inline(described_class.new(journal:, grouped_emoji_reactions: mock_detailed_grouped_emoji_reactions)) + + { + thumbs_up: { + count: 3, tooltip_text: "Bob Bobbit, Bob Bobbit and Bob Bobbit", + aria_label: "thumbs up by Bob Bobbit, Bob Bobbit and Bob Bobbit" + }, + thumbs_down: { + count: 1, tooltip_text: "Bob Bobbit", aria_label: "thumbs down by Bob Bobbit" + }, + eyes: { + count: 20, tooltip_text: "Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit and 15 others", + aria_label: "eyes by Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit and 15 others" + }, + rocket: { + count: 5, tooltip_text: "Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit and Bob Bobbit", + aria_label: "rocket by Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit and Bob Bobbit" + }, + confused_face: { + count: 6, + tooltip_text: "Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit and 1 other", + aria_label: "confused face by Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit, Bob Bobbit and 1 other" + } + }.each { |reaction, details| expect_emoji_reaction(reaction:, **details) } + end + end + + context "with no reactions" do + it "does not render" do + render_inline(described_class.new(journal:, grouped_emoji_reactions: {})) + + expect(page.text).to be_empty + end + end + + def expect_emoji_reaction(reaction:, count:, tooltip_text:, aria_label:) + expect(page).to have_test_selector("reaction-#{reaction}", text: "#{EmojiReaction.emoji(reaction)} #{count}", + aria: { label: aria_label }) + expect(page).to have_test_selector("reaction-tooltip-#{reaction}", text: tooltip_text) + end + + def mock_detailed_grouped_emoji_reactions + users = build_stubbed_list(:user, 20).map { |user| { id: user.id, name: user.name } } + + { + thumbs_up: { users: users.sample(3), count: 3 }, + thumbs_down: { users: users.sample(1), count: 1 }, + eyes: { users: users, count: 20 }, + rocket: { users: users.sample(5), count: 5 }, + confused_face: { users: users.sample(6), count: 6 } + } + end +end diff --git a/spec/contracts/emoji_reactions/base_contract_spec.rb b/spec/contracts/emoji_reactions/base_contract_spec.rb new file mode 100644 index 000000000000..435fd8919f63 --- /dev/null +++ b/spec/contracts/emoji_reactions/base_contract_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe EmojiReactions::BaseContract do + include_context "ModelContract shared context" + + let(:contract) { described_class.new(emoji_reaction, user) } + let(:user) { build_stubbed(:admin) } + let(:emoji_reaction) { build_stubbed(:emoji_reaction, user:) } + + before do + User.current = user + allow(User).to receive(:exists?).with(user.id).and_return(true) + end + + describe "admin user" do + it_behaves_like "contract is valid" + end + + describe "non-admin user" do + context "with valid permissions" do + let(:user) { build_stubbed(:user) } + + before do + mock_permissions_for(user) do |mock| + mock.allow_in_project(:add_work_package_notes, project: emoji_reaction.reactable.project) + end + end + + it_behaves_like "contract is valid" + end + + context "without valid permissions" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + end + + describe "validate user exists" do + context "when user does not exist" do + before { allow(User).to receive(:exists?).with(user.id).and_return(false) } + + it_behaves_like "contract is invalid", user: :error_not_found + end + end + + describe "validate acting user" do + context "when the current user is different from the reactable acting user" do + let(:different_user) { build_stubbed(:user) } + + before do + allow(User).to receive(:exists?).with(different_user.id).and_return(true) + emoji_reaction.user = different_user + end + + it_behaves_like "contract is invalid", user: :error_unauthorized + end + end + + describe "validate reactable object" do + context "when reactable is blank" do + before { emoji_reaction.reactable = nil } + + it_behaves_like "contract is invalid", reactable: :error_not_found + end + + context "when reactable is a work package" do + let(:work_package) { build_stubbed(:work_package) } + + before { emoji_reaction.reactable = work_package } + + it_behaves_like "contract is valid" + end + + context "when reactable is a journal" do + let(:journal) { build_stubbed(:work_package_journal) } + + before { emoji_reaction.reactable = journal } + + it_behaves_like "contract is valid" + end + + context "when reactable is neither a journal nor a work package" do + let(:unknown_object) { build_stubbed(:project) } + + before { emoji_reaction.reactable = unknown_object } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/contracts/emoji_reactions/delete_contract_spec.rb b/spec/contracts/emoji_reactions/delete_contract_spec.rb new file mode 100644 index 000000000000..3e31870534fb --- /dev/null +++ b/spec/contracts/emoji_reactions/delete_contract_spec.rb @@ -0,0 +1,48 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe EmojiReactions::DeleteContract do + include_context "ModelContract shared context" + + let(:contract) { described_class.new(emoji_reaction, current_user) } + let(:current_user) { build_stubbed(:admin) } + let(:emoji_reaction) { build_stubbed(:emoji_reaction, user: current_user) } + + context "when user is different from the one that created the reaction" do + let(:another_user) { build_stubbed(:admin) } + + before { emoji_reaction.user = another_user } + + it_behaves_like "contract user is unauthorized" + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/factories/emoji_reactions_factory.rb b/spec/factories/emoji_reactions_factory.rb new file mode 100644 index 000000000000..6979a7bb36fd --- /dev/null +++ b/spec/factories/emoji_reactions_factory.rb @@ -0,0 +1,35 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +FactoryBot.define do + factory :emoji_reaction do + user + reaction { EmojiReaction.reactions.keys.sample } + reactable factory: :work_package_journal + end +end diff --git a/spec/features/activities/work_package/emoji_reactions_spec.rb b/spec/features/activities/work_package/emoji_reactions_spec.rb new file mode 100644 index 000000000000..7a97e3462731 --- /dev/null +++ b/spec/features/activities/work_package/emoji_reactions_spec.rb @@ -0,0 +1,165 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "Emoji reactions on work package activity", :js, :with_cuprite, + with_flag: { primerized_work_package_activities: true } do + let(:project) { create(:project) } + let(:admin) { create(:admin) } + let(:member) { create_user_as_project_member } + let(:viewer) { create_user_with_view_work_packages_permission } + let(:viewer_with_commenting_permission) { create_user_with_view_and_commenting_permission } + + let(:work_package) { create(:work_package, project:, author: admin) } + let(:first_comment) do + create(:work_package_journal, user: admin, notes: "First comment by admin", journable: work_package, + version: 2) + end + + let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } + let(:activity_tab) { Components::WorkPackages::EmojiReactions.new(work_package) } + + context "when user is the work package author" do + current_user { member } + + let(:work_package) do + create(:work_package, project:, author: member, subject: "Test work package") + end + + before do + first_comment + + create(:emoji_reaction, user: admin, reactable: first_comment, reaction: :thumbs_down) + + wp_page.visit! + wp_page.wait_for_activity_tab + end + + it "can add emoji reactions and remove own reactions" do + activity_tab.add_first_emoji_reaction_for_journal(first_comment, "👍") + activity_tab.add_emoji_reaction_for_journal(first_comment, "👎") + activity_tab.expect_emoji_reactions_for_journal(first_comment, "👍" => 1, "👎" => 2) + + activity_tab.remove_emoji_reaction_for_journal(first_comment, "👎") + activity_tab.expect_emoji_reactions_for_journal(first_comment, "👍" => 1, "👎" => 1) + end + end + + context "when user only has `view_work_packages` permissions" do + current_user { viewer } + + before do + first_comment + + create(:emoji_reaction, user: admin, reactable: first_comment, reaction: :thumbs_down) + + wp_page.visit! + wp_page.wait_for_activity_tab + end + + it "cannot add an emoji reactions but can view emoji reactions by other users" do + activity_tab.expect_no_add_reactions_button + + activity_tab.expect_emoji_reactions_for_journal(first_comment, "👎" => { count: 1, disabled: true }) + end + end + + context "when a user has `add_work_package_notes` permission" do + current_user { viewer_with_commenting_permission } + + before do + first_comment + + create(:emoji_reaction, user: admin, reactable: first_comment, reaction: :rocket) + + wp_page.visit! + wp_page.wait_for_activity_tab + end + + it "can add emoji reactions and remove own reactions" do + activity_tab.add_first_emoji_reaction_for_journal(first_comment, "😄") + activity_tab.add_emoji_reaction_for_journal(first_comment, "🚀") + activity_tab.expect_emoji_reactions_for_journal(first_comment, "😄" => 1, "🚀" => 2) + + activity_tab.remove_emoji_reaction_for_journal(first_comment, "🚀") + activity_tab.expect_emoji_reactions_for_journal(first_comment, "😄" => 1, "🚀" => 1) + end + end + + context "when project is public", with_settings: { login_required: false } do + let(:project) { create(:project, public: true) } + let!(:anonymous_role) do + create(:anonymous_role, permissions: %i[view_project view_work_packages]) + end + + context "when visited by an anonymous visitor" do + before do + first_comment + create(:emoji_reaction, user: admin, reactable: first_comment, reaction: :party_popper) + + login_as User.anonymous + + wp_page.visit! + wp_page.wait_for_activity_tab + end + + it "cannot add an emoji reactions but can view emoji reactions by other users" do + activity_tab.expect_no_add_reactions_button + + activity_tab.expect_emoji_reactions_for_journal(first_comment, "🎉" => { count: 1, disabled: true }) + end + end + end + + def create_user_as_project_member + member_role = create(:project_role, + permissions: %i[view_work_packages edit_work_packages add_work_packages work_package_assigned + add_work_package_notes]) + create(:user, firstname: "A", lastname: "Member", + member_with_roles: { project => member_role }) + end + + def create_user_with_view_work_packages_permission + viewer_role = create(:project_role, permissions: %i[view_work_packages]) + create(:user, + firstname: "A", + lastname: "Viewer", + member_with_roles: { project => viewer_role }) + end + + def create_user_with_view_and_commenting_permission + viewer_role_with_commenting_permission = create(:project_role, + permissions: %i[view_work_packages add_work_package_notes + edit_own_work_package_notes]) + create(:user, + firstname: "A", + lastname: "Viewer", + member_with_roles: { project => viewer_role_with_commenting_permission }) + end +end diff --git a/spec/models/concerns/reactable_spec.rb b/spec/models/concerns/reactable_spec.rb new file mode 100644 index 000000000000..edb119e63cb5 --- /dev/null +++ b/spec/models/concerns/reactable_spec.rb @@ -0,0 +1,167 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Reactable do + shared_let(:work_package) { create(:work_package) } + + let(:wp_journal1) { create(:work_package_journal, journable: work_package, version: 2) } + let(:wp_journal2) { create(:work_package_journal, journable: work_package, version: 3) } + + let(:user1) { create(:user) } + let(:user2) { create(:user) } + + let(:thumbs_up_reactions) do + [user1, user2].each do |user| + create(:emoji_reaction, reactable: wp_journal1, user: user, reaction: :thumbs_up) + end + end + + let(:thumbs_down_reactions) { create(:emoji_reaction, reactable: wp_journal2, user: user2, reaction: :thumbs_down) } + + before do + thumbs_up_reactions + thumbs_down_reactions + end + + describe "Associations" do + it { expect(wp_journal1).to have_many(:emoji_reactions) } + end + + describe ".grouped_work_package_journals_emoji_reactions" do + it "returns grouped emoji reactions for work package journals" do + result = Journal.grouped_work_package_journals_emoji_reactions(work_package) + + expect(result.size).to eq(2) + + expect(result[wp_journal1.id].size).to eq(1) + expect(result[wp_journal1.id][:thumbs_up]).to eq( + count: 2, + users: [{ id: user1.id, name: user1.name }, { id: user2.id, name: user2.name }] + ) + + expect(result[wp_journal2.id].size).to eq(1) + expect(result[wp_journal2.id][:thumbs_down]).to eq( + count: 1, + users: [{ id: user2.id, name: user2.name }] + ) + end + + context "when last_updated_at is set" do + it "returns grouped emoji reactions for work package journals that were updated after last_updated_at" do + wp_journal1.update!(updated_at: 1.day.ago) + result = Journal.grouped_work_package_journals_emoji_reactions(work_package, last_updated_at: wp_journal2.updated_at) + + expect(result).to eq( + wp_journal2.id => { + thumbs_down: { + count: 1, + users: [{ id: user2.id, name: user2.name }] + } + } + ) + end + end + end + + describe ".grouped_journal_emoji_reactions" do + context "with a single reactable" do + it "returns grouped emoji reactions for that journal" do + result = Journal.grouped_journal_emoji_reactions(wp_journal1) + + expect(result).to eq( + wp_journal1.id => { + thumbs_up: { + count: 2, + users: [{ id: user1.id, name: user1.name }, { id: user2.id, name: user2.name }] + } + } + ) + end + end + end + + describe ".grouped_emoji_reactions" do + it "returns grouped emoji reactions" do + result = Journal.grouped_emoji_reactions(reactable_id: work_package.journal_ids, reactable_type: "Journal") + + expect(result.size).to eq({ "thumbs_up" => 2, "thumbs_down" => 1 }) + + expect(result[0].reaction).to eq("thumbs_up") + expect(result[0].count).to eq(2) + expect(result[0].user_details).to eq([[user1.id, user1.name], [user2.id, user2.name]]) + + expect(result[1].reaction).to eq("thumbs_down") + expect(result[1].count).to eq(1) + expect(result[1].user_details).to eq([[user2.id, user2.name]]) + end + + context "when user format is set to :username", with_settings: { user_format: :username } do + it "returns grouped emoji reactions with usernames" do + result = Journal.grouped_emoji_reactions(reactable_id: work_package.journal_ids, reactable_type: "Journal") + + expect(result[0].user_details).to eq([[user1.id, user1.login], [user2.id, user2.login]]) + end + end + + context "when user format is set to :firstname", with_settings: { user_format: :firstname } do + it "returns grouped emoji reactions with first and last names" do + result = Journal.grouped_emoji_reactions(reactable_id: wp_journal2.id, reactable_type: "Journal") + + expect(result[0].user_details).to eq([[user2.id, user2.firstname]]) + end + end + + context "when user format is set to :lastname_coma_firstname", with_settings: { user_format: :lastname_coma_firstname } do + it "returns grouped emoji reactions with last coma firstname" do + result = Journal.grouped_emoji_reactions(reactable_id: wp_journal1.id, reactable_type: "Journal") + + expect(result[0].user_details).to eq( + [ + [user1.id, "#{user1.lastname}, #{user1.firstname}"], + [user2.id, "#{user2.lastname}, #{user2.firstname}"] + ] + ) + end + end + + context "when user format is set to :lastname_n_firstname", with_settings: { user_format: :lastname_n_firstname } do + it "returns grouped emoji reactions with last firstname" do + result = Journal.grouped_emoji_reactions(reactable_id: wp_journal1.id, reactable_type: "Journal") + + expect(result[0].user_details).to eq( + [ + [user1.id, "#{user1.lastname}#{user1.firstname}"], + [user2.id, "#{user2.lastname}#{user2.firstname}"] + ] + ) + end + end + end +end diff --git a/spec/models/emoji_reaction_spec.rb b/spec/models/emoji_reaction_spec.rb new file mode 100644 index 000000000000..bb4d697bd1b7 --- /dev/null +++ b/spec/models/emoji_reaction_spec.rb @@ -0,0 +1,83 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe EmojiReaction do + describe "Associations" do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:reactable) } + end + + describe "Enums" do + it do + expect(subject).to define_enum_for(:reaction) + .with_values( + thumbs_up: "thumbs_up", + thumbs_down: "thumbs_down", + grinning_face_with_smiling_eyes: "grinning_face_with_smiling_eyes", + confused_face: "confused_face", + heart: "heart", + party_popper: "party_popper", + rocket: "rocket", + eyes: "eyes" + ) + .backed_by_column_of_type(:string) + end + + it "stores the reaction identifier" do + emoji_reaction = create(:emoji_reaction, reaction: :thumbs_up) + expect(emoji_reaction.reaction).to eq("thumbs_up") + end + end + + describe "Validations" do + it { is_expected.to validate_presence_of(:reaction) } + + it do + emoji_reaction = create(:emoji_reaction) + expect(emoji_reaction).to validate_uniqueness_of(:user_id).scoped_to(%i[reactable_type reactable_id reaction]) + end + end + + describe ".available_emojis" do + it "returns the available emojis as HTML codes" do + expect(described_class.available_emojis).to eq(["👍", "👎", "😄", "😕", "❤", "🎉", "🚀", "👀"]) + end + end + + describe ".emoji" do + it "returns the emoji for a given reaction" do + expect(described_class.emoji("thumbs_up")).to eq("👍") + end + + it "returns nil if no reaction exists with given name" do + expect(described_class.emoji("rock_on")).to be_nil + end + end +end diff --git a/spec/services/emoji_reactions/create_service_spec.rb b/spec/services/emoji_reactions/create_service_spec.rb new file mode 100644 index 000000000000..72af3755da34 --- /dev/null +++ b/spec/services/emoji_reactions/create_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_create_service" + +RSpec.describe EmojiReactions::CreateService, type: :model do + it_behaves_like "BaseServices create service" do + let(:factory) { :emoji_reaction } + end +end diff --git a/spec/services/emoji_reactions/delete_service_spec.rb b/spec/services/emoji_reactions/delete_service_spec.rb new file mode 100644 index 000000000000..3fc9dca49d9b --- /dev/null +++ b/spec/services/emoji_reactions/delete_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_delete_service" + +RSpec.describe EmojiReactions::DeleteService, type: :model do + it_behaves_like "BaseServices delete service" do + let(:factory) { :emoji_reaction } + end +end diff --git a/spec/support/components/work_packages/emoji_reactions.rb b/spec/support/components/work_packages/emoji_reactions.rb new file mode 100644 index 000000000000..75afde01a650 --- /dev/null +++ b/spec/support/components/work_packages/emoji_reactions.rb @@ -0,0 +1,86 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Components + module WorkPackages + class EmojiReactions < Activities + def add_first_emoji_reaction_for_journal(journal, emoji) + within_journal_entry(journal) do + click_on "Add reaction" + click_on emoji + end + end + + def toggle_emoji_reaction_for_journal(journal, emoji) + within_journal_entry(journal) do + page.within_test_selector("emoji-reactions") do + click_on emoji + end + end + end + alias add_emoji_reaction_for_journal toggle_emoji_reaction_for_journal + alias remove_emoji_reaction_for_journal toggle_emoji_reaction_for_journal + + def can_remove_emoji_reaction_for_journal(journal, emoji) + within_journal_entry(journal) do + page.within_test_selector("emoji-reactions") do + click_on emoji + expect(page).to have_no_text(emoji) + end + end + end + + def expect_emoji_reactions_for_journal(journal, emojis_with_expected_options) + within_journal_entry(journal) do + page.within_test_selector("emoji-reactions") do + emojis_with_expected_options.each do |emoji, expected_emoji_options| + case expected_emoji_options + when Integer + expected_emoji_count = expected_emoji_options + capybara_options = {} + when Hash + expected_emoji_count = expected_emoji_options[:count] + capybara_options = expected_emoji_options.except(:count) + end + + expect(page).to have_selector(:link_or_button, text: "#{emoji} #{expected_emoji_count}", **capybara_options) + end + end + end + end + + def expect_add_reactions_button + expect(page).to have_test_selector("add-reactions-button") + end + + def expect_no_add_reactions_button + expect(page).not_to have_test_selector("add-reactions-button") + end + end + end +end