Files
openproject/app/controllers/work_packages/activities_tab_controller.rb
T
2026-02-09 15:08:07 +01:00

485 lines
15 KiB
Ruby

# frozen_string_literal: true
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2010-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.
# ++
class WorkPackages::ActivitiesTabController < ApplicationController
include OpTurbo::ComponentStream
include FlashMessagesOutputSafetyHelper
include WorkPackages::ActivitiesTab::JournalSortingInquirable
include WorkPackages::ActivitiesTab::StimulusControllers
before_action :find_work_package
before_action :find_journal, only: %i[emoji_actions item_actions edit cancel_edit update toggle_reaction]
before_action :set_filter
before_action :authorize
before_action :initialize_pagination, only: %i[index page_streams]
def index
render(
WorkPackages::ActivitiesTab::LazyIndexComponent.new(
work_package: @work_package,
journals: @paginated_journals,
paginator: @paginator,
filter: @filter,
last_server_timestamp: get_current_server_timestamp
),
layout: false
)
end
def page_streams
replace_via_turbo_stream(
component: WorkPackages::ActivitiesTab::Journals::PageComponent.new(
journals: @paginated_journals,
emoji_reactions: wp_journals_emoji_reactions,
page: @paginator.page,
filter: @filter
)
)
respond_with_turbo_streams
end
def update_streams
set_last_server_timestamp_to_headers
perform_update_streams_from_last_update_timestamp
respond_with_turbo_streams
end
def update_filter
# update the whole tab to reflect the new filtering in all components
# we need to call replace in order to properly re-init the index stimulus component
replace_whole_tab
respond_with_turbo_streams
end
def update_sorting
if params[:sorting].present?
call = Users::UpdateService.new(user: User.current, model: User.current).call(
pref: { comments_sorting: params[:sorting] }
)
if call.success?
# update the whole tab to reflect the new sorting in all components
# we need to call replace in order to properly re-init the index stimulus component
replace_whole_tab
else
@turbo_status = :bad_request
end
else
@turbo_status = :bad_request
end
respond_with_turbo_streams
end
def emoji_actions
render WorkPackages::ActivitiesTab::Journals::ItemComponent::AddReactions
.new(journal: @journal, grouped_emoji_reactions: grouped_emoji_reactions_for_journal),
layout: false
end
def item_actions
render WorkPackages::ActivitiesTab::Journals::ItemComponent::Actions.new(@journal),
layout: false
end
def edit
if allowed_to_edit?(@journal)
update_item_edit_component(journal: @journal)
else
@turbo_status = :forbidden
end
respond_with_turbo_streams
end
def cancel_edit
if allowed_to_edit?(@journal)
update_item_show_component(journal: @journal, grouped_emoji_reactions: grouped_emoji_reactions_for_journal)
else
@turbo_status = :forbidden
end
respond_with_turbo_streams
end
def create
begin
call = create_journal_service_call
if call.success? && call.result
set_last_server_timestamp_to_headers
handle_successful_create_call(call)
else
handle_failed_create_or_update_call(call)
end
rescue StandardError => e
handle_internal_server_error(e)
end
respond_with_turbo_streams
end
def update
begin
call = update_journal_service_call
if call.success? && call.result
update_item_show_component(journal: call.result, grouped_emoji_reactions: grouped_emoji_reactions_for_journal)
else
handle_failed_create_or_update_call(call)
end
rescue StandardError => e
handle_internal_server_error(e)
end
respond_with_turbo_streams
end
def sanitize_internal_mentions
render plain: sanitized_journal_notes
rescue StandardError => e
handle_internal_server_error(e)
respond_with_turbo_streams
end
def toggle_reaction # rubocop:disable Metrics/AbcSize
emoji_reaction_service = EmojiReactions::ToggleEmojiReactionService
.call(user: User.current,
reactable: @journal,
reaction: params[:reaction])
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 find_work_package
@work_package = WorkPackage.visible.find(params[:work_package_id])
@project = @work_package.project
rescue ActiveRecord::RecordNotFound
respond_with_error(I18n.t("label_not_found"))
end
def initialize_pagination
@paginator, @paginated_journals = WorkPackages::ActivitiesTab::Paginator
.paginate(@work_package, params.merge(filter: @filter, limit: 20))
end
def respond_with_error(error_message)
@turbo_status = :not_found
render_error_flash_message_via_turbo_stream(message: error_message)
respond_to_with_turbo_streams do |format|
format.html do
render(
WorkPackages::ActivitiesTab::ErrorFrameComponent.new(error_message: error_message),
layout: false,
status: :not_found
)
end
end
end
def find_journal
@journal = Journal
.with_sequence_version
.find(params[:id])
rescue ActiveRecord::RecordNotFound
respond_with_error(I18n.t("label_not_found"))
end
def set_filter
@filter = (params[:filter] || params.dig(:journal, :filter))&.to_sym || :all
end
def sanitized_journal_notes
WorkPackages::ActivitiesTab::InternalCommentMentionsSanitizer.sanitize(@work_package, journal_params[:notes])
end
def journal_params
params.expect(journal: %i[notes internal])
end
def handle_successful_create_call(call)
if @filter == :only_changes
handle_only_changes_filter_on_create
else
handle_other_filters_on_create(call)
end
end
def handle_only_changes_filter_on_create
@filter = :all # reset filter
# we need to update the whole tab in order to reset the filter
# as the added journal would not be shown otherwise
replace_whole_tab
end
def handle_other_filters_on_create(call)
if call.result.initial?
update_index_component # update the whole index component to reset empty state
else
perform_update_streams_from_last_update_timestamp
end
end
def perform_update_streams_from_last_update_timestamp
last_update_timestamp = params[:last_update_timestamp] || params.dig(:journal, :last_update_timestamp)
editing_journals = params[:editing_journals]&.split(",")&.map(&:to_i) || []
if last_update_timestamp.present?
last_updated_at = Time.zone.parse(last_update_timestamp)
generate_time_based_update_streams(last_updated_at, editing_journals)
generate_work_package_journals_emoji_reactions_update_streams
else
@turbo_status = :bad_request
end
end
def handle_failed_create_or_update_call(call)
@turbo_status = if call.errors&.first&.type == :error_unauthorized
:forbidden
else
:bad_request
end
render_error_flash_message_via_turbo_stream(
message: call.errors&.full_messages&.join(", ")
)
end
def handle_internal_server_error(error)
@turbo_status = :internal_server_error
render_error_flash_message_via_turbo_stream(
message: error.message
)
end
def replace_whole_tab
initialize_pagination # re-initialize pagination to pick up changes to sorting/filtering
replace_via_turbo_stream(
component: WorkPackages::ActivitiesTab::LazyIndexComponent.new(
work_package: @work_package,
journals: @paginated_journals,
paginator: @paginator,
filter: @filter,
last_server_timestamp: get_current_server_timestamp
)
)
end
def update_index_component
initialize_pagination # re-initialize pagination to pick up changes to sorting/filtering
update_via_turbo_stream(
component: WorkPackages::ActivitiesTab::Journals::LazyIndexComponent.new(
work_package: @work_package,
journals: @paginated_journals,
paginator: @paginator,
filter: @filter
)
)
end
def create_journal_service_call
internal = to_boolean(journal_params[:internal], false)
notes = internal ? sanitized_journal_notes : journal_params[:notes]
AddWorkPackageNoteService
.new(user: User.current,
work_package: @work_package)
.call(notes,
send_notifications: to_boolean(params[:notify], true),
internal:)
end
def to_boolean(value, default)
ActiveRecord::Type::Boolean.new.cast(value.presence || default)
end
def update_journal_service_call
notes = @journal.internal? ? sanitized_journal_notes : journal_params[:notes]
Journals::UpdateService.new(model: @journal, user: User.current).call(notes:)
end
def generate_time_based_update_streams(last_update_timestamp, editing_journals)
journals = @work_package
.journals
.internal_visible
.with_sequence_version
if @filter == :only_comments
journals = journals.where.not(notes: "")
end
grouped_emoji_reactions = EmojiReactions::GroupedQueries.grouped_emoji_reactions_by_reactable(
reactable_id: journals.pluck(:id), reactable_type: "Journal"
)
rerender_updated_journals(journals, last_update_timestamp, grouped_emoji_reactions, editing_journals)
rerender_journals_with_updated_notification(journals, last_update_timestamp, grouped_emoji_reactions, editing_journals)
insert_latest_journals_via_turbo_stream(journals, last_update_timestamp, grouped_emoji_reactions)
if journals.present?
remove_potential_empty_state
update_activity_counter
end
end
def generate_work_package_journals_emoji_reactions_update_streams
@work_package.journals.each do |journal|
update_via_turbo_stream(
component: WorkPackages::ActivitiesTab::Journals::ItemComponent::Reactions.new(
journal:,
grouped_emoji_reactions: wp_journals_emoji_reactions[journal.id] || {}
)
)
end
end
def rerender_updated_journals(journals, last_update_timestamp, grouped_emoji_reactions, editing_journals)
journals.where("updated_at > ?", last_update_timestamp).find_each do |journal|
next if editing_journals.include?(journal.id)
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, grouped_emoji_reactions, editing_journals)
Notification
.where(journal_id: journals.pluck(:id))
.where(recipient_id: User.current.id)
.where("notifications.updated_at > ?", last_update_timestamp)
.find_each do |notification|
next if editing_journals.include?(notification.journal_id)
update_item_show_component(
journal: journals.find(notification.journal_id), # take the journal from the journals querried with sequence_version!
grouped_emoji_reactions: grouped_emoji_reactions.fetch(notification.journal_id, {})
)
end
end
def insert_latest_journals_via_turbo_stream(journals, last_update_timestamp, emoji_reactions)
target_component = WorkPackages::ActivitiesTab::Journals::LazyIndexComponent.new(
work_package: @work_package,
journals: Journal.none, # we do not need to pass any journals here since we just want the component key
paginator: nil,
filter: @filter
)
journals.where("created_at > ?", last_update_timestamp).find_each do |journal|
insert_via_turbo_stream(
target_component:,
component: WorkPackages::ActivitiesTab::Journals::ItemComponent.new(
journal:, filter: @filter, grouped_emoji_reactions: emoji_reactions.fetch(journal.id, {})
),
action: journal_sorting.asc? ? :append : :prepend
)
end
end
def remove_potential_empty_state
# remove the empty state if it is present
remove_via_turbo_stream(
component: WorkPackages::ActivitiesTab::Journals::EmptyComponent.new
)
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!
replace_via_turbo_stream(
component: WorkPackages::Details::UpdateCounterComponent.new(work_package: @work_package, menu_name: "activity")
)
end
def wp_journals_emoji_reactions
@wp_journals_emoji_reactions ||= EmojiReactions::GroupedQueries
.grouped_work_package_journals_emoji_reactions_by_reactable(@work_package)
end
def grouped_emoji_reactions_for_journal
EmojiReactions::GroupedQueries
.grouped_emoji_reactions_by_reactable(reactable: @journal)[@journal.id]
end
def allowed_to_edit?(journal)
journal.editable_by?(User.current)
end
def get_current_server_timestamp
# single source of truth for the server timestamp format
Time.current.iso8601(3)
end
def set_last_server_timestamp_to_headers
# Add server timestamp to response in order to let the client be in sync with the server
response.headers["X-Server-Timestamp"] = get_current_server_timestamp
end
end