diff --git a/modules/backlogs/app/views/backlogs/backlog/show.html.erb b/modules/backlogs/app/views/backlogs/backlog/show.html.erb
index 2f3d31b5246a..58d2eeff1a3e 100644
--- a/modules/backlogs/app/views/backlogs/backlog/show.html.erb
+++ b/modules/backlogs/app/views/backlogs/backlog/show.html.erb
@@ -49,11 +49,8 @@ See COPYRIGHT and LICENSE files for more details.
class: "op-backlogs-page",
data: {
controller: "backlogs--list-refresh backlogs--split-view-sync sortable-lists",
- action: "#{Backlogs::WorkPackagesController::WORK_PACKAGE_MOVED_EVENT}@document->backlogs--split-view-sync#onWorkPackageMoved",
- sortable_lists_accepted_type_value: "work_package",
- sortable_lists_move_url_template_value: backlogs_move_url_template(@project),
- sortable_lists_sortable_lists__list_outlet: "#backlogs_container [data-controller~='sortable-lists--list']",
- sortable_lists_sortable_lists__item_outlet: "#backlogs_container [data-controller~='sortable-lists--item']"
+ action: "#{Backlogs::WorkPackagesController::WORK_PACKAGE_MOVED_EVENT}@document->backlogs--split-view-sync#onWorkPackageMoved sortable-lists:moved@document->backlogs--split-view-sync#onSortableListsMoved",
+ sortable_lists_move_url_templates_value: { work_package: backlogs_move_url_template(@project) }.to_json
} %>
<% end %>
diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb
index 3c183bae94be..a9e657734e60 100644
--- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb
@@ -127,6 +127,14 @@ def render_component
end
end
+ it "outlets the list controller to the backlogs root and accepts work packages" do
+ expect(rendered_component).to have_css(".Box") do |box|
+ expect(box["data-sortable-lists--list-sortable-lists-outlet"]).to eq("#backlogs_container")
+ expect(box["data-sortable-lists--list-accepted-types-value"]).to eq('["work_package"]')
+ expect(box["data-sortable-lists--list-rows-container-selector-value"]).to eq(":scope > ul")
+ end
+ end
+
it "renders the shared work-package row menu with inbox src" do
expect(rendered_component).to have_element(
"include-fragment",
diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb
index c1d88d96edd7..7e4cefdc8ff3 100644
--- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb
@@ -73,6 +73,14 @@ def render_component
end
end
+ it "outlets the list controller to the backlogs root and accepts work packages" do
+ expect(page).to have_css(".Box#inbox_project_#{project.id}") do |box|
+ expect(box["data-sortable-lists--list-sortable-lists-outlet"]).to eq("#backlogs_container")
+ expect(box["data-sortable-lists--list-accepted-types-value"]).to eq('["work_package"]')
+ expect(box["data-sortable-lists--list-rows-container-selector-value"]).to eq(":scope > ul")
+ end
+ end
+
it "announces dynamic empty-state updates" do
expect(page).to have_role(:status, aria: { live: "polite" })
end
diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb
index 86ae5ab0b905..3d4414578038 100644
--- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb
@@ -110,6 +110,14 @@ def menu_items
end
end
+ it "outlets the list controller to the backlogs root and accepts work packages" do
+ expect(rendered_component).to have_css(".Box") do |box|
+ expect(box["data-sortable-lists--list-sortable-lists-outlet"]).to eq("#backlogs_container")
+ expect(box["data-sortable-lists--list-accepted-types-value"]).to eq('["work_package"]')
+ expect(box["data-sortable-lists--list-rows-container-selector-value"]).to eq(":scope > ul")
+ end
+ end
+
it "passes an explicit sprint test selector to the shared box" do
expect(rendered_component).to have_css(".Box[data-test-selector='sprint-#{sprint.id}']")
end
diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb
index def10b3239f7..23d69795b413 100644
--- a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb
@@ -294,6 +294,14 @@ def render_component(work_packages:, container:, drag_and_drop:)
expect(box["data-sortable-lists--list-id-value"]).to eq(sprint.id.to_s)
end
end
+
+ it "outlets the list controller to the backlogs root and accepts work packages" do
+ expect(rendered_component).to have_css(".Box") do |box|
+ expect(box["data-sortable-lists--list-sortable-lists-outlet"]).to eq("#backlogs_container")
+ expect(box["data-sortable-lists--list-accepted-types-value"]).to eq('["work_package"]')
+ expect(box["data-sortable-lists--list-rows-container-selector-value"]).to eq(":scope > ul")
+ end
+ end
end
end
diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb
index 6b957bab192e..82a7c25ac460 100644
--- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb
@@ -68,6 +68,7 @@
expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}")
expect(item.row_args[:data]).to include(
controller: "sortable-lists--item",
+ sortable_lists__item_sortable_lists_outlet: "#backlogs_container",
sortable_lists__item_id_value: work_package.id,
sortable_lists__item_type_value: "work_package"
)
diff --git a/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb b/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb
index 1d523255ec98..1c3678a00292 100644
--- a/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb
+++ b/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb
@@ -141,6 +141,46 @@
end
end
+ context "when the inbox is truncated" do
+ # tail_size = [TRUNCATE_MIDDLE / 5, 1].max = 1, so with TRUNCATE_MIDDLE
+ # stubbed to 2, the visible window is first(2) + last(1); the truncate
+ # threshold (TRUNCATE_MIDDLE + tail_size * 2 = 4) stays below the 5 work
+ # packages the outer example group already sets up, so the inbox truncates.
+ before do
+ stub_const("Backlogs::InboxComponent::TRUNCATE_MIDDLE", 2)
+ end
+
+ it "keeps the server-rendered truncation window consistent after a same-list drag" do
+ backlogs_page.visit!
+
+ backlogs_page.expect_work_packages_in_inbox_in_order(work_packages: [inbox_wp1, inbox_wp2])
+ backlogs_page.expect_inbox_item(inbox_wp5)
+ backlogs_page.expect_no_inbox_item(inbox_wp3)
+ backlogs_page.expect_no_inbox_item(inbox_wp4)
+ backlogs_page.expect_inbox_show_more
+ expect(backlogs_page.inbox_truncation_marker_previous_item_id).to eq(inbox_wp4.id.to_s)
+
+ # Same list, but the drop target (the inbox) is truncated: the server
+ # must recompute the visible window rather than trust the optimistic
+ # client-side reorder, so this move settles via a turbo-stream frame
+ # reload rather than the 204/sortable-lists:moved path a plain same-list
+ # drag takes. `cross_list: true` steers drag_work_package to wait on
+ # that frame reload (see sortable-lists.controller.ts's `optimistic`
+ # gate and Backlogs::InboxComponent's TRUNCATE_MIDDLE window).
+ backlogs_page
+ .drag_work_package(inbox_wp1, before: inbox_wp5, cross_list: true)
+
+ # inbox_wp1 moved behind inbox_wp4 (the item the marker names), pushing
+ # it out of the visible head and pulling inbox_wp3 into view instead.
+ backlogs_page.expect_work_packages_in_inbox_in_order(work_packages: [inbox_wp2, inbox_wp3])
+ backlogs_page.expect_inbox_item(inbox_wp5)
+ backlogs_page.expect_no_inbox_item(inbox_wp1)
+ backlogs_page.expect_no_inbox_item(inbox_wp4)
+ backlogs_page.expect_inbox_show_more
+ expect(backlogs_page.inbox_truncation_marker_previous_item_id).to eq(inbox_wp1.id.to_s)
+ end
+ end
+
context "when lacking the permission to manage sprint items" do
current_user do
create(:user,
diff --git a/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb b/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb
index 6e4f4debc5db..4ab52f8b2c1e 100644
--- a/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb
+++ b/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb
@@ -143,7 +143,7 @@
end
it "keeps drop indicators active after moving a bucket item into the sprint" do
- backlogs_page.drag_work_package(bucket_wp2, before: sprint1_wp4)
+ backlogs_page.drag_work_package(bucket_wp2, before: sprint1_wp4, cross_list: true)
backlogs_page.expect_work_packages_in_sprint_in_order(
sprint1,
work_packages: [sprint1_wp1, sprint1_wp2, sprint1_wp3, bucket_wp2, sprint1_wp4]
diff --git a/modules/backlogs/spec/features/work_packages/edit_in_split_view_after_move_spec.rb b/modules/backlogs/spec/features/work_packages/edit_in_split_view_after_move_spec.rb
index a75104700359..61f3b5e76f60 100644
--- a/modules/backlogs/spec/features/work_packages/edit_in_split_view_after_move_spec.rb
+++ b/modules/backlogs/spec/features/work_packages/edit_in_split_view_after_move_spec.rb
@@ -96,4 +96,34 @@ def move_work_package(target)
it_behaves_like "editing works after the move"
end
+
+ # A cross-list drag gets a server-dispatched WORK_PACKAGE_MOVED_EVENT (see
+ # the controller's #move), which is how the split view above learns to
+ # refresh its cached lock_version. A same-list drag is answered with a bare
+ # 204 and dispatches no such event; the split view instead relies on the
+ # client-side sortable-lists:moved event (see split-view-sync.controller.ts).
+ # This scenario exercises that client-only path.
+ context "when moving the work package within its own list by dragging it with the mouse", :selenium do
+ let!(:other_work_package_in_sprint) { create(:work_package, project:, sprint:) }
+ let!(:third_work_package_in_sprint) { create(:work_package, project:, sprint:) }
+
+ it "updates the work package without a conflict error" do
+ backlogs_page.visit!
+
+ split_view = backlogs_page.open_work_package_details(work_package)
+
+ backlogs_page.drag_work_package(work_package, before: third_work_package_in_sprint)
+
+ backlogs_page.expect_work_packages_in_sprint_in_order(
+ sprint,
+ work_packages: [other_work_package_in_sprint, work_package, third_work_package_in_sprint]
+ )
+ split_view.expect_attributes(sprint:)
+
+ split_view.edit_field(:subject).update("Updated after move")
+
+ backlogs_page.expect_and_dismiss_toaster(message: "Successful update")
+ expect(work_package.reload.subject).to eq("Updated after move")
+ end
+ end
end
diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb
index 57933834ec06..e8b813422c2e 100644
--- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb
+++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb
@@ -88,17 +88,9 @@
expect(response).to have_turbo_frame "backlogs_container"
expect(response.body).to include('class="op-sprint-planning-container"')
expect(response.body).to include('data-controller="backlogs--list-refresh sortable-lists"')
- expect(response.body).to include('data-sortable-lists-accepted-type-value="work_package"')
expect(response.body).to include(
- %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move")
- )
- expect(response.body).to include(
- "data-sortable-lists-sortable-lists--list-outlet=" \
- "\"#backlogs_container [data-controller~='sortable-lists--list']\""
- )
- expect(response.body).to include(
- "data-sortable-lists-sortable-lists--item-outlet=" \
- "\"#backlogs_container [data-controller~='sortable-lists--item']\""
+ %(data-sortable-lists-move-url-templates-value="{"work_package":") \
+ "/projects/#{project.identifier}/backlogs/work_packages/{id}/move"}\""
)
expect(response.body).to include('id="owner_backlogs_container"')
expect(response.body).to include('id="sprint_backlogs_container"')
@@ -115,7 +107,7 @@
move_url_template =
"/projects/#{project.identifier}/backlogs/work_packages/{id}/move?all=true"
expect(response.body).to include(
- %(data-sortable-lists-move-url-template-value="#{move_url_template}")
+ %(data-sortable-lists-move-url-templates-value="{"work_package":"#{move_url_template}"}")
)
end
diff --git a/modules/backlogs/spec/requests/backlogs/work_packages_move_spec.rb b/modules/backlogs/spec/requests/backlogs/work_packages_move_spec.rb
new file mode 100644
index 000000000000..c3ddb45f519f
--- /dev/null
+++ b/modules/backlogs/spec/requests/backlogs/work_packages_move_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe "Backlogs work package move", :skip_csrf, type: :rails_request do
+ shared_let(:type_feature) { create(:type_feature) }
+ shared_let(:user) { create(:admin) }
+ shared_let(:status) { create(:status, name: "status 1", is_default: true) }
+ shared_let(:project) { create(:project) }
+ shared_let(:sprint) { create(:sprint, project:) }
+ shared_let(:story_one) { create(:work_package, status:, sprint:, project:) }
+ shared_let(:story_two) { create(:work_package, status:, sprint:, project:) }
+
+ current_user { user }
+
+ describe "PUT #move" do
+ context "with an optimistic same-list reorder" do
+ it "responds 204 without a body and persists the reorder" do
+ put move_project_backlogs_work_package_path(project, story_one),
+ headers: { "Accept" => "text/vnd.turbo-stream.html" },
+ params: { prev_id: story_two.id, list_type: "sprint", list_id: sprint.id, optimistic: "true" }
+
+ expect(response).to have_http_status(:no_content)
+ expect(response.body).to be_empty
+ expect(story_one.reload.position).to be > story_two.reload.position
+ end
+ end
+
+ context "with a same-list reorder without the optimistic param (menu move)" do
+ it "responds with a turbo-stream frame reload and persists the reorder" do
+ put move_project_backlogs_work_package_path(project, story_one),
+ headers: { "Accept" => "text/vnd.turbo-stream.html" },
+ params: { prev_id: story_two.id, list_type: "sprint", list_id: sprint.id }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(response.body).to include("backlogs_container")
+ expect(story_one.reload.position).to be > story_two.reload.position
+ end
+ end
+
+ context "with a same-list reorder and optimistic explicitly false" do
+ it "responds with a turbo-stream frame reload, not a 204, and persists the reorder" do
+ put move_project_backlogs_work_package_path(project, story_one),
+ headers: { "Accept" => "text/vnd.turbo-stream.html" },
+ params: { prev_id: story_two.id, list_type: "sprint", list_id: sprint.id, optimistic: "false" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(response.body).to include("backlogs_container")
+ expect(story_one.reload.position).to be > story_two.reload.position
+ end
+ end
+
+ context "with an optimistic cross-list move" do
+ it "responds with a turbo-stream frame reload and persists the move" do
+ put move_project_backlogs_work_package_path(project, story_one),
+ headers: { "Accept" => "text/vnd.turbo-stream.html" },
+ params: { prev_id: "", list_type: "inbox", list_id: "", optimistic: "true" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(response.body).to include("backlogs_container")
+ expect(story_one.reload.sprint_id).to be_nil
+ end
+ end
+
+ context "with a failing move" do
+ it "responds with an error flash stream" do
+ put move_project_backlogs_work_package_path(project, story_one),
+ headers: { "Accept" => "text/vnd.turbo-stream.html" },
+ params: { list_type: "unknown", list_id: "1" }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(response.body).to include(
+ I18n.t(:notice_unsuccessful_update_with_reason,
+ reason: I18n.t("backlogs.stories.update_service.invalid_target_type"))
+ )
+ end
+ end
+ end
+end
diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb
index f630d4d004ce..4317fd2db06b 100644
--- a/modules/backlogs/spec/support/pages/backlog.rb
+++ b/modules/backlogs/spec/support/pages/backlog.rb
@@ -167,6 +167,17 @@ def click_inbox_show_more
wait_for_backlogs_network_idle
end
+ # The truncation marker row's data-sortable-lists-prev-item-id names the
+ # last work package collapsed behind the "show more" row (the DOM contract
+ # sortable-lists relies on to anchor drops next to a hidden block). Reading
+ # it back lets specs assert the server recomputed the truncated window
+ # rather than trusting a stale client-side reorder.
+ def inbox_truncation_marker_previous_item_id
+ within_backlog_inbox do
+ find(".op-work-package-card-list--show-more-row", visible: :all)["data-sortable-lists-prev-item-id"]
+ end
+ end
+
def expect_work_packages_in_inbox_in_order(work_packages: [])
within_backlog_inbox do
expect_work_packages_in_order work_packages:
@@ -546,17 +557,20 @@ def expect_no_filter_count(type)
end
end
- def drag_work_package(moved, before: nil, into: nil)
+ # A drag within one list is optimistic: the server answers 204 and no frame
+ # reload happens, so the settle signal is the sortable-lists:moved event. A
+ # cross-list drag still reloads the backlogs_container frame. Callers of
+ # drag_work_package(before:) must pass cross_list: true whenever the move
+ # takes the frame-reload response path: crossing lists, or staying within a
+ # truncated list (the client omits the optimistic param there, so the
+ # server streams a reload).
+ def drag_work_package(moved, before: nil, into: nil, cross_list: into.present?)
raise ArgumentError, "You must specify either before or into" unless before.present? ^ into.present?
moved_element = find(draggable_work_package_selector(moved))
- target_element = if before
- find(work_package_selector(before))
- else
- find(sprint_selector(into))
- end
+ target_element = before ? find(work_package_selector(before)) : find(sprint_selector(into))
- wait_for_backlogs_turbo_stream(frame_reload: true) do
+ wait_for_drag_work_package(cross_list:) do
drag_backlogs_item(source: moved_element, target: target_element, edge: before ? :top : nil)
end
rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError
@@ -829,6 +843,19 @@ def wait_for_backlogs_turbo_stream(wait: Capybara.default_max_wait_time, frame_r
end
end
+ # cross_list: true waits for the backlogs_container frame reload — used for
+ # genuine cross-list drags and for same-list drags in truncated lists, both
+ # of which take the stream-reload response. A plain same-list drag is
+ # optimistic (204, no reload) and only ever settles via the
+ # sortable-lists:moved event.
+ def wait_for_drag_work_package(cross_list:, &)
+ if cross_list
+ wait_for_backlogs_turbo_stream(frame_reload: true, &)
+ else
+ wait_for_sortable_lists_moved(&)
+ end
+ end
+
def install_backlogs_dnd_probe(source:, target:, edge:)
page.execute_script(<<~JS, source, target, edge&.to_s)
window.__opBacklogsDndProbeAbort?.abort();
diff --git a/spec/support/capybara/wait_helpers.rb b/spec/support/capybara/wait_helpers.rb
index 206152e06356..b04378fc5a70 100644
--- a/spec/support/capybara/wait_helpers.rb
+++ b/spec/support/capybara/wait_helpers.rb
@@ -112,6 +112,22 @@ def wait_for_turbo_frame(frame: nil, wait: Capybara.default_max_wait_time, &)
wait_for_browser_event("turbo:frame-load", target_id: frame&.to_s, wait:, &)
end
+ # Executes the given block and waits for a sortable-lists move to settle.
+ #
+ # The sortable-lists Stimulus root dispatches `sortable-lists:moved` after a
+ # successful move request, strictly after its moving flag has been cleared —
+ # so when this returns, the list accepts the next drag. Same-list optimistic
+ # moves answer with 204 and never reload the frame, making this event the
+ # only reliable settle signal for them.
+ #
+ # @param wait [Integer, true, false, nil] seconds to wait; +true+ uses
+ # Capybara's default wait time, a falsey value skips the wait and just runs the block
+ # @yield the actions that trigger the move
+ # @return [Object] the block's return value
+ def wait_for_sortable_lists_moved(wait: Capybara.default_max_wait_time, &)
+ wait_for_browser_event("sortable-lists:moved", wait:, &)
+ end
+
private
# Shared implementation for the +wait_for_turbo*+ helpers.