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