From afa5c77b9a87434c47c51d6d7c53886dbc769850 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:43:53 +0300 Subject: [PATCH 1/8] [#73737] Improve Backlogs and Sprints page performance by deferring story menu loading https://community.openproject.org/wp/73737 --- .../backlogs/backlog_component.html.erb | 2 +- .../components/backlogs/backlog_component.rb | 4 --- .../backlogs/inbox_component.html.erb | 8 ++---- .../components/backlogs/inbox_component.rb | 9 ++---- .../backlogs/inbox_item_component.html.erb | 21 ++++++++++---- .../backlogs/inbox_item_component.rb | 7 ++--- .../backlogs/inbox_menu_component.html.erb | 28 +++++++++---------- .../backlogs/inbox_menu_component.rb | 18 ++++++------ .../backlogs/sprint_component.html.erb | 2 +- .../components/backlogs/sprint_component.rb | 4 --- .../backlogs/story_component.html.erb | 16 ++++++++++- .../components/backlogs/story_component.rb | 5 ++-- .../backlogs/story_menu_component.html.erb | 27 ++++++++---------- .../backlogs/story_menu_component.rb | 16 +++++------ .../app/controllers/inbox_controller.rb | 18 ++++++++++-- .../app/controllers/rb_stories_controller.rb | 21 ++++++++++++-- .../rb_master_backlogs/_backlog_list.html.erb | 3 +- modules/backlogs/config/locales/en.yml | 4 +-- modules/backlogs/config/routes.rb | 2 ++ .../lib/open_project/backlogs/engine.rb | 3 +- .../backlogs/inbox_component_spec.rb | 2 -- .../backlogs/inbox_item_component_spec.rb | 5 ++-- .../backlogs/inbox_menu_component_spec.rb | 3 +- .../backlogs/story_component_spec.rb | 7 +++-- .../backlogs/story_menu_component_spec.rb | 3 +- .../spec/controllers/inbox_controller_spec.rb | 23 +++++++++++++++ .../controllers/rb_stories_controller_spec.rb | 21 ++++++++++++++ .../open_project/backlogs/permissions_spec.rb | 4 +++ .../spec/routing/inbox_routing_spec.rb | 9 ++++++ .../spec/routing/rb_stories_routing_spec.rb | 10 +++++++ 30 files changed, 198 insertions(+), 107 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/backlog_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_component.html.erb index dd18db687a75..5ce7f8ffec2c 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_component.html.erb @@ -56,7 +56,7 @@ See COPYRIGHT and LICENSE files for more details. ), tabindex: 0 ) do %> - <%= render(Backlogs::StoryComponent.new(story:, project:, sprint:, max_position:)) %> + <%= render(Backlogs::StoryComponent.new(story:, project:, sprint:)) %> <% end %> <% end %> <% end %> diff --git a/modules/backlogs/app/components/backlogs/backlog_component.rb b/modules/backlogs/app/components/backlogs/backlog_component.rb index 888de7921d63..5d17e318a82f 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.rb +++ b/modules/backlogs/app/components/backlogs/backlog_component.rb @@ -65,10 +65,6 @@ def folded? current_user.backlogs_preference(:versions_default_fold_state) == "closed" end - def max_position - stories.filter_map(&:position).max - end - def drop_target_config { generic_drag_and_drop_target: "container", diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index 81a3d13708a6..9165cd7de7f7 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -76,9 +76,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render Backlogs::InboxItemComponent.with_collection( paginate? ? first_page : work_packages, container: border_box, - project:, - max_position:, - open_sprints_exist: + project: ) %> <% if paginate? %> @@ -110,9 +108,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render Backlogs::InboxItemComponent.with_collection( last_page, container: border_box, - project:, - max_position:, - open_sprints_exist: + project: ) %> <% end %> <% end %> diff --git a/modules/backlogs/app/components/backlogs/inbox_component.rb b/modules/backlogs/app/components/backlogs/inbox_component.rb index bce41a65225f..6749070823dd 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.rb +++ b/modules/backlogs/app/components/backlogs/inbox_component.rb @@ -38,16 +38,15 @@ class InboxComponent < ApplicationComponent FIRST_PAGE_SIZE = 50 LAST_PAGE_SIZE = 10 - attr_reader :work_packages, :project, :current_user, :show_all, :open_sprints_exist + attr_reader :work_packages, :project, :current_user, :show_all - def initialize(work_packages:, project:, open_sprints_exist:, show_all: false, current_user: User.current, **system_arguments) + def initialize(work_packages:, project:, show_all: false, current_user: User.current, **system_arguments) super() @work_packages = work_packages @project = project @show_all = show_all @current_user = current_user - @open_sprints_exist = open_sprints_exist @system_arguments = system_arguments @system_arguments[:id] = inbox_dom_id @@ -100,9 +99,5 @@ def drop_target_config target_allowed_drag_type: "story" } end - - def max_position - work_packages.maximum(:position) - end end end diff --git a/modules/backlogs/app/components/backlogs/inbox_item_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_item_component.html.erb index 984d03e2ecdb..b56c2466e644 100644 --- a/modules/backlogs/app/components/backlogs/inbox_item_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_item_component.html.erb @@ -49,12 +49,21 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> <% grid.with_area(:menu) do %> - <%= render Backlogs::InboxMenuComponent.new( - work_package:, - project:, - max_position:, - open_sprints_exist: - ) %> + <%= render( + Primer::Alpha::ActionMenu.new( + menu_id: dom_target(work_package, :menu), + src: menu_project_inbox_path(project, work_package), + anchor_align: :end, + classes: "hide-when-print" + ) + ) do |menu| %> + <% menu.with_show_button( + scheme: :invisible, + icon: :"kebab-horizontal", + "aria-label": t(".label_actions"), + tooltip_direction: :se + ) %> + <% end %> <% end %> <% grid.with_area(:subject) do %> <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { work_package.subject } %> diff --git a/modules/backlogs/app/components/backlogs/inbox_item_component.rb b/modules/backlogs/app/components/backlogs/inbox_item_component.rb index 554fbf45cd58..bad17f8b371d 100644 --- a/modules/backlogs/app/components/backlogs/inbox_item_component.rb +++ b/modules/backlogs/app/components/backlogs/inbox_item_component.rb @@ -32,17 +32,14 @@ module Backlogs class InboxItemComponent < ApplicationComponent include OpPrimer::ComponentHelpers - attr_reader :work_package, :project, :container, :max_position, :current_user, :open_sprints_exist + attr_reader :work_package, :project, :container, :current_user - def initialize(inbox_item:, project:, container:, max_position:, open_sprints_exist:, - current_user: User.current) + def initialize(inbox_item:, project:, container:, current_user: User.current) super() @work_package = inbox_item @project = project @container = container - @max_position = max_position - @open_sprints_exist = open_sprints_exist @current_user = current_user end diff --git a/modules/backlogs/app/components/backlogs/inbox_menu_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_menu_component.html.erb index 0d6614f107b1..94fd84e98f91 100644 --- a/modules/backlogs/app/components/backlogs/inbox_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_menu_component.html.erb @@ -28,15 +28,8 @@ See COPYRIGHT and LICENSE files for more details. ++# %> <%= - render(Primer::Alpha::ActionMenu.new(**@system_arguments)) do |menu| - menu.with_show_button( - scheme: :invisible, - icon: :"kebab-horizontal", - "aria-label": t(".label_actions"), - tooltip_direction: :se - ) - - menu.with_item( + render(Primer::Alpha::ActionMenu::List.new(menu_id:)) do |list| + list.with_item( id: dom_target(work_package, :menu, :open_details), tag: :a, label: t(:"js.button_open_details"), @@ -46,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :"op-view-split") end - menu.with_item( + list.with_item( id: dom_target(work_package, :menu, :open_fullscreen), tag: :a, label: t(:"js.button_open_fullscreen"), @@ -56,7 +49,7 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :"screen-full") end - menu.with_item( + list.with_item( id: dom_target(work_package, :menu, :copy_url_to_clipboard), tag: :"clipboard-copy", label: t(".action_menu.copy_url_to_clipboard"), @@ -65,7 +58,7 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :copy) end - menu.with_item( + list.with_item( id: dom_target(work_package, :menu, :copy_work_package_id), tag: :"clipboard-copy", label: t(".action_menu.copy_work_package_id"), @@ -75,9 +68,14 @@ See COPYRIGHT and LICENSE files for more details. end if show_move_submenu? - menu.with_divider - - menu.with_sub_menu_item(label: t(".action_menu.move_menu")) do |move_menu| + list.with_divider + + list.with_item( + component_klass: Primer::Alpha::ActionMenu::SubMenuItem, + label: t(".action_menu.move_menu"), + select_variant: :none, + form_arguments: {} + ) do |move_menu| move_menu.with_leading_visual_icon(icon: :"op-arrow-in") with_item_group(move_menu) do diff --git a/modules/backlogs/app/components/backlogs/inbox_menu_component.rb b/modules/backlogs/app/components/backlogs/inbox_menu_component.rb index 28331cfe4aa6..02d6ffa4195a 100644 --- a/modules/backlogs/app/components/backlogs/inbox_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/inbox_menu_component.rb @@ -29,27 +29,25 @@ #++ module Backlogs + # Renders Primer::Alpha::ActionMenu::List for the deferred menu (InboxController#menu). + # +menu_id+ must match the row ActionMenu in InboxItemComponent. class InboxMenuComponent < ApplicationComponent include OpPrimer::ComponentHelpers attr_reader :work_package, :project, :max_position, :current_user, :open_sprints_exist - def initialize(work_package:, project:, max_position:, open_sprints_exist:, current_user: User.current, **system_arguments) + def initialize(work_package:, project:, max_position:, open_sprints_exist:, current_user: User.current) super() @work_package = work_package @project = project - @max_position = max_position @current_user = current_user + @max_position = max_position @open_sprints_exist = open_sprints_exist + end - @system_arguments = system_arguments - @system_arguments[:menu_id] = dom_target(work_package, :menu) - @system_arguments[:anchor_align] = :end - @system_arguments[:classes] = class_names( - @system_arguments[:classes], - "hide-when-print" - ) + def menu_id + dom_target(work_package, :menu) end private @@ -60,7 +58,7 @@ def show_move_submenu? def show_move_items? allowed_to_manage_sprint_items? && - !(first_item? && last_item?) + !(first_item? && last_item?) end def show_move_to_sprint? diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index c6c1bbd5c06c..675a2ce4ccf3 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -49,7 +49,7 @@ See COPYRIGHT and LICENSE files for more details. data: story_data_attribute(story), tabindex: 0 ) do %> - <%= render(Backlogs::StoryComponent.new(story:, sprint:, project:, max_position:)) %> + <%= render(Backlogs::StoryComponent.new(story:, sprint:, project:)) %> <% end %> <% end %> <% end %> diff --git a/modules/backlogs/app/components/backlogs/sprint_component.rb b/modules/backlogs/app/components/backlogs/sprint_component.rb index 1cc7e06696b1..ad819f90219f 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_component.rb @@ -67,10 +67,6 @@ def folded? current_user.backlogs_preference(:versions_default_fold_state) == "closed" end - def max_position - stories.filter_map(&:position).max - end - def drop_target_config { generic_drag_and_drop_target: "container", diff --git a/modules/backlogs/app/components/backlogs/story_component.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb index 37dd1cc7bc00..9465c851ea9f 100644 --- a/modules/backlogs/app/components/backlogs/story_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -51,7 +51,21 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% grid.with_area(:menu) do %> - <%= render(Backlogs::StoryMenuComponent.new(story:, sprint:, project:, max_position:)) %> + <%= render( + Primer::Alpha::ActionMenu.new( + menu_id: dom_target(story, :menu), + src: menu_backlogs_project_sprint_story_path(project, sprint, story), + anchor_align: :end, + classes: "hide-when-print" + ) + ) do |menu| %> + <% menu.with_show_button( + scheme: :invisible, + icon: :"kebab-horizontal", + "aria-label": t(".label_actions"), + tooltip_direction: :se + ) %> + <% end %> <% end %> <% grid.with_area(:subject) do %> diff --git a/modules/backlogs/app/components/backlogs/story_component.rb b/modules/backlogs/app/components/backlogs/story_component.rb index 50f2841b6d12..3e23fb94a323 100644 --- a/modules/backlogs/app/components/backlogs/story_component.rb +++ b/modules/backlogs/app/components/backlogs/story_component.rb @@ -32,15 +32,14 @@ module Backlogs class StoryComponent < ApplicationComponent include OpPrimer::ComponentHelpers - attr_reader :story, :sprint, :project, :max_position, :current_user + attr_reader :story, :sprint, :project, :current_user - def initialize(story:, sprint:, project:, max_position:, current_user: User.current) + def initialize(story:, sprint:, project:, current_user: User.current) super() @story = story @sprint = sprint @project = project - @max_position = max_position @current_user = current_user end diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb index 3b9f1d4b560a..7d8d01fe645c 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb @@ -28,15 +28,8 @@ See COPYRIGHT and LICENSE files for more details. ++# %> <%= - render(Primer::Alpha::ActionMenu.new(**@system_arguments)) do |menu| - menu.with_show_button( - scheme: :invisible, - icon: :"kebab-horizontal", - "aria-label": t(".label_actions"), - tooltip_direction: :se - ) - - menu.with_item( + render(Primer::Alpha::ActionMenu::List.new(menu_id:)) do |list| + list.with_item( id: dom_target(story, :menu, :open_details), tag: :a, label: t(:"js.button_open_details"), @@ -46,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :"op-view-split") end - menu.with_item( + list.with_item( id: dom_target(story, :menu, :open_fullscreen), tag: :a, label: t(:"js.button_open_fullscreen"), @@ -56,7 +49,7 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :"screen-full") end - menu.with_item( + list.with_item( id: dom_target(story, :menu, :copy_url_to_clipboard), tag: :"clipboard-copy", label: t(".action_menu.copy_url_to_clipboard"), @@ -65,7 +58,7 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :copy) end - menu.with_item( + list.with_item( id: dom_target(story, :menu, :copy_work_package_id), tag: :"clipboard-copy", label: t(".action_menu.copy_work_package_id"), @@ -75,9 +68,13 @@ See COPYRIGHT and LICENSE files for more details. end if show_move_items? - menu.with_divider - - menu.with_sub_menu_item(label: t(".action_menu.move_menu")) do |move_menu| + list.with_divider + list.with_item( + component_klass: Primer::Alpha::ActionMenu::SubMenuItem, + label: t(".action_menu.move_menu"), + select_variant: :none, + form_arguments: {} + ) do |move_menu| move_menu.with_leading_visual_icon(icon: :"op-arrow-in") with_item_group(move_menu) do diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.rb b/modules/backlogs/app/components/backlogs/story_menu_component.rb index 4f486f81a158..9d65a9b31886 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.rb @@ -29,12 +29,14 @@ #++ module Backlogs + # Renders Primer::Alpha::ActionMenu::List for the deferred menu (RbStoriesController#menu). + # +menu_id+ must match the row ActionMenu in StoryComponent. class StoryMenuComponent < ApplicationComponent include OpPrimer::ComponentHelpers attr_reader :story, :sprint, :project, :max_position, :current_user - def initialize(story:, sprint:, project:, max_position:, current_user: User.current, **system_arguments) + def initialize(story:, sprint:, project:, max_position:, current_user: User.current) super() @story = story @@ -42,21 +44,17 @@ def initialize(story:, sprint:, project:, max_position:, current_user: User.curr @project = project @max_position = max_position @current_user = current_user + end - @system_arguments = system_arguments - @system_arguments[:menu_id] = dom_target(story, :menu) - @system_arguments[:anchor_align] = :end - @system_arguments[:classes] = class_names( - @system_arguments[:classes], - "hide-when-print" - ) + def menu_id + dom_target(story, :menu) end private def show_move_items? allowed_to_manage_sprint_items? && - !(first_item? && last_item?) + !(first_item? && last_item?) end def allowed_to_manage_sprint_items? diff --git a/modules/backlogs/app/controllers/inbox_controller.rb b/modules/backlogs/app/controllers/inbox_controller.rb index 653009c6a728..605549c473d7 100644 --- a/modules/backlogs/app/controllers/inbox_controller.rb +++ b/modules/backlogs/app/controllers/inbox_controller.rb @@ -34,6 +34,21 @@ class InboxController < RbApplicationController before_action :not_authorized_on_feature_flag_inactive before_action :load_work_package + # Deferred ActionMenu items (Primer include-fragment). + def menu + max_position = Backlog.inbox_for(project: @project).maximum(:position) || 0 + open_sprints_exist = Agile::Sprint.for_project(@project).visible.not_completed.exists? + + render(Backlogs::InboxMenuComponent.new( + work_package: @work_package, + project: @project, + max_position:, + open_sprints_exist:, + current_user: + ), + layout: false) + end + def move_to_sprint_dialog respond_with_dialog Backlogs::MoveToSprintDialogComponent.new( work_package: @work_package, @@ -79,8 +94,7 @@ def replace_inbox_component_via_turbo_stream replace_via_turbo_stream( component: Backlogs::InboxComponent.new( work_packages:, - project: @project, - open_sprints_exist: true + project: @project ), method: :morph ) diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 488d598c5f29..84ae03359a46 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -33,6 +33,24 @@ class RbStoriesController < RbApplicationController before_action :load_story + # Deferred ActionMenu items (Primer include-fragment). + def menu + work_packages = if OpenProject::FeatureDecisions.scrum_projects_active? + WorkPackage.where(sprint_id: @sprint.id, project_id: @project.id) + else + WorkPackage.where(version_id: @sprint.id, project_id: @project.id) + end + + render(Backlogs::StoryMenuComponent.new( + story: @story, + sprint: @sprint, + project: @project, + max_position: work_packages.maximum(:position) || 0, + current_user: + ), + layout: false) + end + # Move a story from a Sprint to another Sprint or an Agile::Sprint. def move_legacy # The update service reloads the story internally (via #move_after), @@ -128,9 +146,8 @@ def moved_to_inbox message: I18n.t(:notice_successful_move, from: @sprint.name, to: I18n.t(:label_inbox)) ) work_packages = Backlog.inbox_for(project: @project) - open_sprints_exist = Agile::Sprint.for_project(@project).not_completed.exists? replace_via_turbo_stream( - component: Backlogs::InboxComponent.new(work_packages:, project: @project, open_sprints_exist:), + component: Backlogs::InboxComponent.new(work_packages:, project: @project), method: :morph ) end diff --git a/modules/backlogs/app/views/rb_master_backlogs/_backlog_list.html.erb b/modules/backlogs/app/views/rb_master_backlogs/_backlog_list.html.erb index a31c53d9ff00..598080216ae0 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/_backlog_list.html.erb +++ b/modules/backlogs/app/views/rb_master_backlogs/_backlog_list.html.erb @@ -35,8 +35,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render Backlogs::InboxComponent.new( work_packages: @inbox_work_packages, project: @project, - show_all: show_all_backlog, - open_sprints_exist: @sprints.any? + show_all: show_all_backlog ) %>
Date: Thu, 9 Apr 2026 11:21:08 +0300 Subject: [PATCH 2/8] Reactivate deferred Primer ActionMenus after morphed turbo stream responses. --- .../src/turbo/action-menu-stream-actions.ts | 88 +++++++++++++++++++ frontend/src/turbo/setup.ts | 2 + 2 files changed, 90 insertions(+) create mode 100644 frontend/src/turbo/action-menu-stream-actions.ts diff --git a/frontend/src/turbo/action-menu-stream-actions.ts b/frontend/src/turbo/action-menu-stream-actions.ts new file mode 100644 index 000000000000..8eee152f3ff4 --- /dev/null +++ b/frontend/src/turbo/action-menu-stream-actions.ts @@ -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. + * ++ + */ + +/** + * Primer `` with `src` (via `Primer::Alpha::ActionMenu`) lazy loads + * the menu items via ``. + * It registers `include-fragment-replaced` only in `connectedCallback`, while Turbo + * Idiomorph can leave the same `` host connected while swapping + * in a new ``. Since the `` is not replaced, the fragment + * replaced event is never fired, the component will stay in a loading state after morph. + * + * Replacing each affected `` host forces `connectedCallback` and + * a correct listener. + * TODO: This fix should be extended to other deferred primer components, including + * `turbo:before-frame-morph`, `turbo:morph` (full page morph) events too. + */ + +interface TurboBeforeStreamRenderDetail { + newStream:Element; + render:(stream:Element) => Promise; +} + +function remountDeferredPrimerActionMenu(root:ParentNode | null):void { + if (!root) { + return; + } + + root.querySelectorAll('action-menu').forEach((menu) => { + if (!menu.querySelector('include-fragment[src]')) { + return; + } + const clone = menu.cloneNode(true) as HTMLElement; + menu.replaceWith(clone); + }); +} + +export function registerActionMenuStreamAction():void { + document.addEventListener('turbo:before-stream-render', (event:Event) => { + const { detail } = event as CustomEvent; + const stream = detail?.newStream as HTMLElement | undefined; + if (!stream) { + return; + } + + if (stream.getAttribute('action') !== 'replace' || + stream.getAttribute('method') !== 'morph') { + return; + } + + const targetId = stream.getAttribute('target'); + if (!targetId) { + return; + } + + const originalRender = detail.render; + detail.render = async (streamElement) => { + await originalRender(streamElement); + remountDeferredPrimerActionMenu(document.getElementById(targetId)); + }; + }); +} diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts index c384b98ab41b..d01f3d5616eb 100644 --- a/frontend/src/turbo/setup.ts +++ b/frontend/src/turbo/setup.ts @@ -11,6 +11,7 @@ import { debugLog, whenDebugging } from 'core-app/shared/helpers/debug_output'; import { TURBO_EVENTS } from './constants'; import { StreamActions } from '@hotwired/turbo'; import { addTurboAngularWrapper } from 'core-turbo/turbo-angular-wrapper'; +import { registerActionMenuStreamAction } from './action-menu-stream-actions'; Turbo.session.drive = true; Turbo.config.drive.progressBarDelay = 100; @@ -37,6 +38,7 @@ whenDebugging(() => { // Register our own actions addTurboEventListeners(); addTurboGlobalListeners(); +registerActionMenuStreamAction(); registerDialogStreamAction(); registerFlashStreamAction(); registerLiveRegionStreamAction(); From 6ca25840ec0957dc39055d76db296bd023c55cbb Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:30:33 +0300 Subject: [PATCH 3/8] Optimize the action menu stream actions. --- .../src/turbo/action-menu-stream-actions.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/turbo/action-menu-stream-actions.ts b/frontend/src/turbo/action-menu-stream-actions.ts index 8eee152f3ff4..116b793e0ddd 100644 --- a/frontend/src/turbo/action-menu-stream-actions.ts +++ b/frontend/src/turbo/action-menu-stream-actions.ts @@ -47,11 +47,7 @@ interface TurboBeforeStreamRenderDetail { render:(stream:Element) => Promise; } -function remountDeferredPrimerActionMenu(root:ParentNode | null):void { - if (!root) { - return; - } - +function remountDeferredPrimerActionMenu(root:ParentNode):void { root.querySelectorAll('action-menu').forEach((menu) => { if (!menu.querySelector('include-fragment[src]')) { return; @@ -79,10 +75,19 @@ export function registerActionMenuStreamAction():void { return; } + const targetElement = document.getElementById(targetId); + if (!targetElement) { + return; + } + + if (!targetElement.querySelector('action-menu include-fragment[src]')) { + return; + } + const originalRender = detail.render; detail.render = async (streamElement) => { await originalRender(streamElement); - remountDeferredPrimerActionMenu(document.getElementById(targetId)); + remountDeferredPrimerActionMenu(targetElement); }; }); } From 7efe0c066502886ac1452f19f33178a50bd1fb7d Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:44:58 +0300 Subject: [PATCH 4/8] Rename StoryMenuComponent to StoryMenuListComponent --- ...ent.html.erb => story_menu_list_component.html.erb} | 0 ..._menu_component.rb => story_menu_list_component.rb} | 2 +- .../backlogs/app/controllers/rb_stories_controller.rb | 2 +- modules/backlogs/config/locales/en.yml | 2 +- ...onent_spec.rb => story_menu_list_component_spec.rb} | 10 +++++----- 5 files changed, 8 insertions(+), 8 deletions(-) rename modules/backlogs/app/components/backlogs/{story_menu_component.html.erb => story_menu_list_component.html.erb} (100%) rename modules/backlogs/app/components/backlogs/{story_menu_component.rb => story_menu_list_component.rb} (98%) rename modules/backlogs/spec/components/backlogs/{story_menu_component_spec.rb => story_menu_list_component_spec.rb} (94%) diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb b/modules/backlogs/app/components/backlogs/story_menu_list_component.html.erb similarity index 100% rename from modules/backlogs/app/components/backlogs/story_menu_component.html.erb rename to modules/backlogs/app/components/backlogs/story_menu_list_component.html.erb diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.rb b/modules/backlogs/app/components/backlogs/story_menu_list_component.rb similarity index 98% rename from modules/backlogs/app/components/backlogs/story_menu_component.rb rename to modules/backlogs/app/components/backlogs/story_menu_list_component.rb index 9d65a9b31886..c4bf7b66cc83 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/story_menu_list_component.rb @@ -31,7 +31,7 @@ module Backlogs # Renders Primer::Alpha::ActionMenu::List for the deferred menu (RbStoriesController#menu). # +menu_id+ must match the row ActionMenu in StoryComponent. - class StoryMenuComponent < ApplicationComponent + class StoryMenuListComponent < ApplicationComponent include OpPrimer::ComponentHelpers attr_reader :story, :sprint, :project, :max_position, :current_user diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 84ae03359a46..e713f8559af5 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -41,7 +41,7 @@ def menu WorkPackage.where(version_id: @sprint.id, project_id: @project.id) end - render(Backlogs::StoryMenuComponent.new( + render(Backlogs::StoryMenuListComponent.new( story: @story, sprint: @sprint, project: @project, diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 19306709a64f..73bea2cf93bc 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -228,7 +228,7 @@ en: label_drag_story: "Move %{name}" label_actions: "Story actions" - story_menu_component: + story_menu_list_component: action_menu: copy_url_to_clipboard: "Copy URL to clipboard" copy_work_package_id: "Copy work package ID" diff --git a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_menu_list_component_spec.rb similarity index 94% rename from modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb rename to modules/backlogs/spec/components/backlogs/story_menu_list_component_spec.rb index 721081cdd2b7..7d0d9e6d826f 100644 --- a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_menu_list_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe Backlogs::StoryMenuComponent, type: :component do +RSpec.describe Backlogs::StoryMenuListComponent, type: :component do shared_let(:type_feature) { create(:type_feature) } shared_let(:type_task) { create(:type_task) } shared_let(:default_status) { create(:default_status) } @@ -106,7 +106,7 @@ def render_component(position: 2, max_position: 3) :"clipboard-copy", id: "story_#{story.id}_menu_copy_url_to_clipboard", value: /\/work_packages\/#{story.id}\z/, - text: I18n.t("backlogs.story_menu_component.action_menu.copy_url_to_clipboard") + text: "Copy URL to clipboard" ) end @@ -118,7 +118,7 @@ def render_component(position: 2, max_position: 3) :"clipboard-copy", id: "story_#{story.id}_menu_copy_work_package_id", value: story.id.to_s, - text: I18n.t("backlogs.story_menu_component.action_menu.copy_work_package_id") + text: "Copy work package ID" ) end @@ -131,7 +131,7 @@ def render_component(position: 2, max_position: 3) it "shows the Move submenu with incoming-arrow icon" do render_component - expect(page).to have_selector(:menuitem, text: I18n.t("backlogs.story_menu_component.action_menu.move_menu")) + expect(page).to have_selector(:menuitem, text: "Move") expect(page).to have_octicon(:"op-arrow-in") end end @@ -225,7 +225,7 @@ def render_component(position: 2, max_position: 3) expect(page).to have_no_selector( :menuitem, - text: I18n.t("backlogs.story_menu_component.action_menu.move_menu") + text: "Move" ) end end From 86617f2a26355fff3d1b004a431c0ab92968bea4 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:55:15 +0300 Subject: [PATCH 5/8] Simplify ActionMenu remounting query selector --- frontend/src/turbo/action-menu-stream-actions.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/turbo/action-menu-stream-actions.ts b/frontend/src/turbo/action-menu-stream-actions.ts index 116b793e0ddd..5e62f6bae211 100644 --- a/frontend/src/turbo/action-menu-stream-actions.ts +++ b/frontend/src/turbo/action-menu-stream-actions.ts @@ -48,13 +48,12 @@ interface TurboBeforeStreamRenderDetail { } function remountDeferredPrimerActionMenu(root:ParentNode):void { - root.querySelectorAll('action-menu').forEach((menu) => { - if (!menu.querySelector('include-fragment[src]')) { - return; - } - const clone = menu.cloneNode(true) as HTMLElement; - menu.replaceWith(clone); - }); + root + .querySelectorAll('action-menu:has(include-fragment[src])') + .forEach((menu) => { + const clone = menu.cloneNode(true); + menu.replaceWith(clone); + }); } export function registerActionMenuStreamAction():void { From f0bb07985342420c77f2981b1932caa64704db88 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:13:39 +0300 Subject: [PATCH 6/8] Hook the deferred ActionMenu remounting fix to the turbo:morph-element event. --- ...ctions.ts => action-menu-morph-remount.ts} | 56 ++++--------------- frontend/src/turbo/setup.ts | 4 +- 2 files changed, 14 insertions(+), 46 deletions(-) rename frontend/src/turbo/{action-menu-stream-actions.ts => action-menu-morph-remount.ts} (55%) diff --git a/frontend/src/turbo/action-menu-stream-actions.ts b/frontend/src/turbo/action-menu-morph-remount.ts similarity index 55% rename from frontend/src/turbo/action-menu-stream-actions.ts rename to frontend/src/turbo/action-menu-morph-remount.ts index 5e62f6bae211..29266102da88 100644 --- a/frontend/src/turbo/action-menu-stream-actions.ts +++ b/frontend/src/turbo/action-menu-morph-remount.ts @@ -37,56 +37,24 @@ * replaced event is never fired, the component will stay in a loading state after morph. * * Replacing each affected `` host forces `connectedCallback` and - * a correct listener. - * TODO: This fix should be extended to other deferred primer components, including - * `turbo:before-frame-morph`, `turbo:morph` (full page morph) events too. + * a correct listener. We hook `turbo:morph-element` so this applies to turbo-stream + * morph, frame morph, and full-page morph alike. */ -interface TurboBeforeStreamRenderDetail { - newStream:Element; - render:(stream:Element) => Promise; +interface TurboMorphElementDetail { + currentElement:HTMLElement; + newElement:HTMLElement; } -function remountDeferredPrimerActionMenu(root:ParentNode):void { - root - .querySelectorAll('action-menu:has(include-fragment[src])') - .forEach((menu) => { - const clone = menu.cloneNode(true); - menu.replaceWith(clone); - }); -} - -export function registerActionMenuStreamAction():void { - document.addEventListener('turbo:before-stream-render', (event:Event) => { - const { detail } = event as CustomEvent; - const stream = detail?.newStream as HTMLElement | undefined; - if (!stream) { - return; - } - - if (stream.getAttribute('action') !== 'replace' || - stream.getAttribute('method') !== 'morph') { - return; - } - - const targetId = stream.getAttribute('target'); - if (!targetId) { - return; - } - - const targetElement = document.getElementById(targetId); - if (!targetElement) { - return; - } - - if (!targetElement.querySelector('action-menu include-fragment[src]')) { +export function registerActionMenuMorphRemount():void { + document.addEventListener('turbo:morph-element', (event:Event) => { + const { detail } = event as CustomEvent; + const currentElement = detail?.currentElement; + if (!currentElement?.matches('action-menu:has(include-fragment[src])')) { return; } - const originalRender = detail.render; - detail.render = async (streamElement) => { - await originalRender(streamElement); - remountDeferredPrimerActionMenu(targetElement); - }; + const clone = currentElement.cloneNode(true); + currentElement.replaceWith(clone); }); } diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts index d01f3d5616eb..2b1f597f3a79 100644 --- a/frontend/src/turbo/setup.ts +++ b/frontend/src/turbo/setup.ts @@ -11,7 +11,7 @@ import { debugLog, whenDebugging } from 'core-app/shared/helpers/debug_output'; import { TURBO_EVENTS } from './constants'; import { StreamActions } from '@hotwired/turbo'; import { addTurboAngularWrapper } from 'core-turbo/turbo-angular-wrapper'; -import { registerActionMenuStreamAction } from './action-menu-stream-actions'; +import { registerActionMenuMorphRemount } from './action-menu-morph-remount'; Turbo.session.drive = true; Turbo.config.drive.progressBarDelay = 100; @@ -38,7 +38,7 @@ whenDebugging(() => { // Register our own actions addTurboEventListeners(); addTurboGlobalListeners(); -registerActionMenuStreamAction(); +registerActionMenuMorphRemount(); registerDialogStreamAction(); registerFlashStreamAction(); registerLiveRegionStreamAction(); From 79c8a48cbe4e3051de5a9bf573a3656485c6c214 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:18:03 +0300 Subject: [PATCH 7/8] Guard the inbox and stories controller that no work package can be accessed if it's not part of the requested story or project. Add menu route to the new routes. --- .../app/controllers/inbox_controller.rb | 2 +- .../app/controllers/rb_stories_controller.rb | 4 +- modules/backlogs/config/routes.rb | 1 + .../spec/controllers/inbox_controller_spec.rb | 9 ++++ .../controllers/rb_stories_controller_spec.rb | 51 +++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/app/controllers/inbox_controller.rb b/modules/backlogs/app/controllers/inbox_controller.rb index 605549c473d7..8b44cfe30f40 100644 --- a/modules/backlogs/app/controllers/inbox_controller.rb +++ b/modules/backlogs/app/controllers/inbox_controller.rb @@ -86,7 +86,7 @@ def move private def load_work_package - @work_package = WorkPackage.visible.find(params[:id]) + @work_package = WorkPackage.visible.where(project: @project).find(params[:id]) end def replace_inbox_component_via_turbo_stream diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index e713f8559af5..54ed403a10eb 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -220,9 +220,9 @@ def replace_sprint_component_via_turbo_stream(sprint:) def load_story @story = if OpenProject::FeatureDecisions.scrum_projects_active? - WorkPackage.visible.find(params[:id]) + WorkPackage.visible.where(sprint: @sprint, project: @project).find(params[:id]) else - Story.visible.find(params[:id]) + Story.visible.where(version: @sprint, project: @project).find(params[:id]) end end diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index d0f95e28abfc..f11657e72911 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -48,6 +48,7 @@ resources :stories, controller: :rb_stories, only: [] do member do + get :menu put :move end end diff --git a/modules/backlogs/spec/controllers/inbox_controller_spec.rb b/modules/backlogs/spec/controllers/inbox_controller_spec.rb index 2192967e4694..29c7f8cec2ed 100644 --- a/modules/backlogs/spec/controllers/inbox_controller_spec.rb +++ b/modules/backlogs/spec/controllers/inbox_controller_spec.rb @@ -216,6 +216,15 @@ expect(response.body).to include(I18n.t(:"js.button_open_details")) end + context "when the work package belongs to another project" do + let(:other_project) { create(:project) } + let(:work_package) { create(:work_package, project: other_project) } + + it "responds with 404" do + expect(response).to have_http_status :not_found + end + end + context "with a user lacking project permission" do let(:user) { create(:user) } diff --git a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb index 6b6b47e43b64..215608223ed1 100644 --- a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb @@ -51,6 +51,57 @@ .and_return({ "story_types" => [type_feature.id], "task_type" => type_task.id }) end + describe "load_story" do + subject do + get :menu, + params: { project_id: project.id, sprint_id: requested_sprint.id, id: load_story_id }, + format: :html + end + + context "when scrum_projects flag is inactive", with_flag: { scrum_projects: false } do + let(:load_story_id) { story.id } + let(:requested_sprint) { version_sprint } + + context "when the story is in the requested sprint" do + it "assigns the visible story", :aggregate_failures do + subject + expect(response).to be_successful + expect(response).to have_http_status :ok + expect(assigns(:story)).to eq(story) + end + end + + context "when the story is not in the requested sprint" do + let(:requested_sprint) { create(:sprint, name: "Sprint load_story other", project:) } + + it { is_expected.to have_http_status :not_found } + end + end + + context "when scrum_projects flag is active", with_flag: { scrum_projects: true } do + let(:agile_sprint) { create(:agile_sprint, name: "Agile Sprint load_story", project:) } + let(:work_package_in_sprint) { create(:work_package, status:, sprint: agile_sprint, project:) } + let(:load_story_id) { work_package_in_sprint.id } + + context "when the work package is in the requested sprint" do + let(:requested_sprint) { agile_sprint } + + it "assigns the visible work package", :aggregate_failures do + subject + expect(response).to be_successful + expect(response).to have_http_status :ok + expect(assigns(:story)).to eq(work_package_in_sprint) + end + end + + context "when the work package is not in the requested sprint" do + let(:requested_sprint) { create(:agile_sprint, name: "Other Sprint load_story", project:) } + + it { is_expected.to have_http_status :not_found } + end + end + end + describe "PUT #move_legacy" do context "with a user lacking project permission" do let(:user) { create(:user) } From 25a7380d9caf9b10a60ac83f2dc9f2c11a67b2fa Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:25:31 +0300 Subject: [PATCH 8/8] Refactor max_position calculations. --- .../app/controllers/rb_stories_controller.rb | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 54ed403a10eb..4696e7035153 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -35,17 +35,13 @@ class RbStoriesController < RbApplicationController # Deferred ActionMenu items (Primer include-fragment). def menu - work_packages = if OpenProject::FeatureDecisions.scrum_projects_active? - WorkPackage.where(sprint_id: @sprint.id, project_id: @project.id) - else - WorkPackage.where(version_id: @sprint.id, project_id: @project.id) - end + max_position = @allowed_stories.maximum(:position) || 0 render(Backlogs::StoryMenuListComponent.new( story: @story, sprint: @sprint, project: @project, - max_position: work_packages.maximum(:position) || 0, + max_position:, current_user: ), layout: false) @@ -219,11 +215,13 @@ def replace_sprint_component_via_turbo_stream(sprint:) end def load_story - @story = if OpenProject::FeatureDecisions.scrum_projects_active? - WorkPackage.visible.where(sprint: @sprint, project: @project).find(params[:id]) - else - Story.visible.where(version: @sprint, project: @project).find(params[:id]) - end + @allowed_stories = + if OpenProject::FeatureDecisions.scrum_projects_active? + WorkPackage.visible.where(sprint: @sprint, project: @project) + else + Story.visible.where(Story.condition(@project, @sprint)) + end + @story = @allowed_stories.find(params[:id]) end def move_params