mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
fd22629702
https://community.openproject.org/wp/STC-462 Two readability passes over the work package activity tab, no behaviour change. The paginator's private methods are reordered to follow their call order so the file reads top-down from `#call`, and the three activity filter modes (`:all`, `:only_comments`, `:only_changes`) — until now bare symbols duplicated across the controller, paginator, journal components and the hidden form — move into a single `WorkPackages::ActivitiesTab::Filters` module so the modes have one source of truth and can't drift apart. The diff reaches beyond the paginator into the controller, several components and a form, since that's where the symbols were scattered.
496 lines
16 KiB
Ruby
496 lines
16 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
|
|
|
|
Filters = WorkPackages::ActivitiesTab::Filters
|
|
|
|
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,
|
|
resolved_anchor: @resolved_anchor
|
|
),
|
|
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
|
|
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: @filter,
|
|
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 = WorkPackages::ActivitiesTab::Paginator.new(@work_package, params.merge(filter: @filter, limit: 20))
|
|
@paginator, @paginated_journals = paginator.call
|
|
@resolved_anchor = paginator.resolved_anchor
|
|
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
|
|
# turbo_stream requests (tab is already rendered and an error occured in subsequent requests) are handled below
|
|
format.turbo_stream do
|
|
@turbo_status = :not_found
|
|
render_error_flash_message_via_turbo_stream(message: error_message)
|
|
render turbo_stream: turbo_streams, status: :not_found
|
|
end
|
|
end
|
|
end
|
|
|
|
def find_journal
|
|
@journal = @work_package
|
|
.journals
|
|
.internal_visible
|
|
.find(params[:id])
|
|
rescue ActiveRecord::RecordNotFound
|
|
respond_with_error(I18n.t("label_not_found"))
|
|
end
|
|
|
|
def set_filter
|
|
@filter = Filters.cast(params[:filter] || params.dig(:journal, :filter))
|
|
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 == Filters::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 = Filters::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,
|
|
resolved_anchor: @resolved_anchor
|
|
)
|
|
)
|
|
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
|
|
|
|
if @filter == Filters::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),
|
|
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
|