Skip to content

[AGILE-278] Move selected work packages atomically - #24778

Open
myabc wants to merge 18 commits into
implementation/AGILE-361-batch-selectionfrom
implementation/AGILE-278-batch-move
Open

[AGILE-278] Move selected work packages atomically#24778
myabc wants to merge 18 commits into
implementation/AGILE-361-batch-selectionfrom
implementation/AGILE-278-batch-move

Conversation

@myabc

@myabc myabc commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Note

This PR is part of a stack. Please review and merge in this order: #24525 → this PR. #24844 has been merged into this PR.

Ticket

https://community.openproject.org/wp/AGILE-278

also incorporates https://community.openproject.org/wp/AGILE-396 (previously approved and merged)

What are you trying to accomplish?

Dragging a selected Backlogs card now moves the complete batch selection as one ordered, atomic operation; dragging an unselected card keeps the existing collapse-and-move-alone behavior. This is the batch-movement slice of AGILE-181, building on the selection capability from #24525.

  • A new collection action (PUT projects/:id/backlogs/work_packages/move) accepts ordered ids[] with a three-state prev_id (after an anchor, top, append) and rejects blanks, duplicates, unresolvable members, invalid targets, and stale or in-batch predecessors outright.
  • Backlogs::WorkPackages::BatchUpdateService chains each member after the previously moved one inside one outer transaction, acquires advisory locks in ascending id order across the batch and its anchor, and revalidates both the anchor and the project cohort under lock — the batch commits as one contiguous block or not at all, and after-commit hooks only ever observe the completed batch.
  • The client freezes the drag's batch at drag start, resolves the drop against the excluded selected ids, moves all rows as one optimistic block with a single guarded rollback boundary, marks every represented row as dragging, and badges the preview with the batch count.
  • Same-list success skips the frame reload only after the persisted rows verifiably form the requested block; cross-list or divergent results reconcile via reload. Success clears the selection, failure preserves it, and an unverifiable rollback announces a check-your-positions warning even when the server's 422 flash is otherwise silent.
  • The moved Turbo event carries ordered work_package_ids from the collection action while the member action keeps its scalar, and the split-view synchronizer consumes both.

What approach did you choose and why?

The collection action is deliberately not an overloaded member action: existing singular callers (menu moves, non-selection sortable roots) keep the member route untouched, while a selection-enabled root submits one or many cards through the collection contract. The service wraps the existing single-work-package UpdateService per member rather than reimplementing the move, so validation and journaling stay in one place; a real BatchFailure exception (never ActiveRecord::Rollback, which joined transactions swallow) carries the failed result out of the transaction. Advisory locks are pre-acquired in canonical ascending-id order because each inner update takes the same per-work-package lock with an unbounded wait — opposing batches would otherwise deadlock. Two subtle acts_as_list staleness bugs surfaced during review and are covered by regression tests: in-memory positions feeding remove_from_list thresholds mid-batch (which could persist duplicate positions), and after-commit hook contexts observing interim positions.

On the client, the frozen batch lives in root-owned state consumed exactly once per drop, so Escape or a Turbo morph mid-drag can never change what is submitted, and the drag preview freezes the batch itself because Pragmatic renders previews before onDragStart fires.

Keep-batch-on-menu-invocation, positional batch actions, and batch destination dialogs are follow-up slices: AGILE-362, AGILE-363, AGILE-364.

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements atomic, ordered batch movement of Backlogs work packages when dragging a selected card, via a new collection move endpoint and corresponding client-side batch drag behavior. This extends the existing sortable-lists selection foundation by adding a batch-aware move contract, server-side locking/validation for correctness under concurrency, and updated announcements/UX affordances (dragging marks + preview badge).

Changes:

  • Added PUT /projects/:id/backlogs/work_packages/move (collection action) and server-side Backlogs::WorkPackages::BatchUpdateService to move ordered batches atomically with advisory locks and revalidation under lock.
  • Updated sortable-lists Stimulus controllers to freeze the drag batch at drag start, submit ordered ids[] to the collection endpoint, exclude selected ids when resolving prev_id, and handle optimistic rollback/announcements for batch moves.
  • Added/updated routing, request, service, and Selenium feature specs; extended i18n strings and internal developer docs (frontend AGENTS).

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
modules/backlogs/spec/support/pages/backlog.rb Adds a drag helper tailored for rejected moves (no frame reload).
modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb New spec coverage for batch move service behavior (atomicity, locks, staleness).
modules/backlogs/spec/routing/backlogs/work_packages_routing_spec.rb Verifies routing for the new collection move endpoint.
modules/backlogs/spec/requests/work_packages/move_collection_spec.rb New request specs for validation, success/failure responses, and invisibility flashes.
modules/backlogs/spec/requests/backlogs/backlog_spec.rb Ensures the Backlogs page includes collection move URL + announcement scope values.
modules/backlogs/spec/features/work_packages/batch_move_spec.rb New Selenium feature spec covering batch drag, collapse behavior, and failure preservation.
modules/backlogs/lib/open_project/backlogs/engine.rb Grants permission for the new move_collection action.
modules/backlogs/config/routes.rb Adds the collection move route mapped to move_collection.
modules/backlogs/config/locales/js-en.yml Adds Backlogs-specific move announcement strings for batch/singular moves.
modules/backlogs/config/locales/en.yml Adds server-side batch move error messages and plural invisible-after-move notice.
modules/backlogs/app/views/backlogs/backlog/show.html.erb Wires new Stimulus values (collection move URL + move announcement scope) into the view.
modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb New atomic batch move service chaining member moves under locks/transaction.
modules/backlogs/app/controllers/backlogs/work_packages_controller.rb Adds move_collection action, validation/loading of ordered batch ids, and optimistic-skip logic.
frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts Adds drag batch snapshotting and post-success silent selection clearing.
frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.spec.ts Unit tests for batch snapshotting and silent clear behavior.
frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts Updates root port mocks to match new drag-batch API.
frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.ts Adds a batch-count badge overlay to the drag preview for multi-row drags.
frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.spec.ts Tests preview badge behavior for batch vs single drags.
frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts Updates root port mocks to match new drag-batch API.
frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts Updates append-resolution helper to exclude a set of ids (batch-aware).
frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts Updates and extends tests for excluded-id append resolution.
frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts Begins drag batch on preview and drag start; passes batch size to preview renderer.
frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts Tests preview badge behavior and beginDragBatch invocation ordering.
frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts Adds ids[] payload support and predecessor resolution that excludes selected/batch ids.
frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts Tests batch form payload and excluded-id predecessor resolution.
frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts Implements frozen drag batch lifecycle, batch row marking/cleanup, collection move submission, and batch announcements/failure handling.
frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts Extensive unit coverage for batch drag/drop behavior, requests, cleanup, and announcements.
frontend/src/stimulus/controllers/dynamic/backlogs/split-view-sync.controller.ts Consumes both singular and batch moved events and refreshes cached work packages accordingly.
frontend/src/stimulus/controllers/dynamic/backlogs/split-view-sync.controller.spec.ts Tests batch event consumption, uncached skipping, and no-id events.
frontend/AGENTS.md Updates internal documentation to reflect new batch drag + move announcement scope behavior.
config/locales/js-en.yml Adds generic sortable-lists batch announcement strings for non-Backlogs consumers.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb Outdated
@myabc myabc added feature DO NOT MERGE javascript Pull requests that update Javascript code pullpreview labels Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

Deploying openproject with PullPreview

Field Value
Latest commit 6bbdbbb
Job deploy
Status 🗑️ Preview destroyed
Preview URL Destroyed

View logs

@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./modules/team_planner/spec/features/query_handling_spec.rb[1:3:1:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #24778, linked for reference only):

- `rspec ./modules/team_planner/spec/features/query_handling_spec.rb[1:3:1:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]`

Treat this as a standalone task, unrelated to PR #24778. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #24778 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @myabc to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @myabc, and request a review from @myabc.
On every commit, set @myabc as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@myabc
myabc force-pushed the implementation/AGILE-278-batch-move branch from a31087a to 320b49b Compare August 15, 2026 21:53
Comment thread modules/backlogs/spec/support/pages/backlog.rb Outdated
Comment thread modules/backlogs/lib/open_project/backlogs/engine.rb Outdated
Comment thread modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb Outdated
@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./spec/features/projects/creation_wizard/wizard_from_template_flow_spec.rb[1:1]
  • rspec ./spec/features/work_packages/table/queries/parent_field_ranking_spec.rb[1:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #24778, linked for reference only):

- `rspec ./spec/features/projects/creation_wizard/wizard_from_template_flow_spec.rb[1:1]`
- `rspec ./spec/features/work_packages/table/queries/parent_field_ranking_spec.rb[1:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]`

Treat this as a standalone task, unrelated to PR #24778. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #24778 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @myabc to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @myabc, and request a review from @myabc.
On every commit, set @myabc as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@myabc
myabc force-pushed the implementation/AGILE-278-batch-move branch from 2ed9b04 to 1773e7f Compare August 17, 2026 12:02
@myabc
myabc marked this pull request as ready for review August 17, 2026 12:21
@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./modules/backlogs/spec/features/inbox_column_spec.rb[1:7:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #24778, linked for reference only):

- `rspec ./modules/backlogs/spec/features/inbox_column_spec.rb[1:7:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]`

Treat this as a standalone task, unrelated to PR #24778. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #24778 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @myabc to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @myabc, and request a review from @myabc.
On every commit, set @myabc as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@myabc
myabc requested a review from ulferts August 18, 2026 13:25
Comment on lines +153 to +159
def with_ordered_locks(entries, index = 0, &)
return yield if index >= entries.length

OpenProject::Mutex.with_advisory_lock_transaction(entries[index]) do
with_ordered_locks(entries, index + 1, &)
end
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ulferts recursion is probably ok in "normal use", but if someone were to submit a massive batch of work packages then the stack would overflow. Should we set a soft-limit somewhere?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ulferts I've set a batch size limit to 500 for the time-being.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the reason why this is a recursion to begin with is that with_advisory_lock_transaction does not provide a non block implementation. That is too bad as it leads to quite a deep stack that might as well have been avoided by an iterative approach.

A simple thing to change is to at least skip the transaction part of the with_advisory_lock_transaction call by with_advisory_lock_transaction(entries[index], nil, transaction: false). The only caller already wraps a transaction around the call.

Claude suggests that it would be possible to completely flatten the stack by this:

Put the name format and the acquisition next to each other in OpenProject::Mutex, so there's one definition of mutex_on__:

def advisory_lock_name(entry, suffix = nil)
  name = +"mutex_on_#{entry.class.name}_#{entry.id}"
  name << "_#{suffix}" if suffix
  name
end

# Takes transaction-scoped advisory locks for several entries in the given
# order, flat rather than one nested block per entry. Requires an open
# transaction: the locks are released when it ends, never before.
#
# Blocking (pg_advisory_xact_lock), unlike with_advisory_lock_transaction,
# which polls pg_try_advisory_xact_lock with a Ruby sleep. The database
# queues the waiter and its deadlock detector sees the wait graph.
def acquire_advisory_locks(entries, suffix = nil)
  connection = ActiveRecord::Base.connection
  raise ArgumentError, "advisory locks require an open transaction" unless connection.transaction_open?

  entries.each do |entry|
    keys = connection.lock_keys_for(advisory_lock_name(entry, suffix)).map { Integer(it) }
    connection.execute("SELECT pg_advisory_xact_lock(#{keys[0]}, #{keys[1]})")
  end
end

and at the call site, with_ordered_locks disappears:

WorkPackage.transaction do
  OpenProject::Mutex.acquire_advisory_locks(lock_entries(placement.anchor))

  revalidate_cohort!
  revalidate_target_availability!(target)
  revalidate_anchor!(placement, target)
  ...
end

@ulferts ulferts Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After more digging as part of reviewing #24779 the above statement needs to be clarified. There is a bug in OpenProject::Mutex.with_advisory_lock_transaction as transaction: false cannot be overwritten. Either that is fixed and/or a method is added to add an advisory_lock without a transaction following the naming schema. This would not flatten the stack completely but would reduce it at least.

@myabc
myabc force-pushed the implementation/AGILE-278-batch-move branch from 1773e7f to 5b40a07 Compare August 18, 2026 13:40
@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./modules/backlogs/spec/features/backlogs/start_finish_spec.rb[1:2:2:2]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]
  • rspec ./spec/features/workflows/edit_spec.rb[1:4:4]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #24778, linked for reference only):

- `rspec ./modules/backlogs/spec/features/backlogs/start_finish_spec.rb[1:2:2:2]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]`
- `rspec ./spec/features/workflows/edit_spec.rb[1:4:4]`

Treat this as a standalone task, unrelated to PR #24778. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #24778 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @myabc to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @myabc, and request a review from @myabc.
On every commit, set @myabc as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./spec/features/projects/create_spec.rb[1:12:3:2:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]
  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #24778, linked for reference only):

- `rspec ./spec/features/projects/create_spec.rb[1:12:3:2:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]`
- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:2]`

Treat this as a standalone task, unrelated to PR #24778. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #24778 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @myabc to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @myabc, and request a review from @myabc.
On every commit, set @myabc as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@ulferts ulferts left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite a mighty PR @myabc . It took me a while to go through this and I relied on AI to help me with it. Given the size, I concentrated on correctness, structure and functionality but I did not go into too much detail. Some errors might have been overlooked and wording and readability were not high up on my priority list.

As a user, the d&d behaviour really feels nice. It feels natural to select a bunch of work packages and drop them wherever it is indicated. I also find it quite nice that it is possible to drag from multiple lists at the same time. My comments next to the code show some areas where improvement is still possible ans some errors are also flagged. But overall, the UX is top notch.

When a page with more content is rendered, performance starts to become a problem. The selection needs a while to initialize after the page loads. If the user then starts dragging even few work packages, the performance becomes even worse with drop indicators being rendered sluggishly. When dragging more work packages, the browser can even freeze. All this happens before the backend is involved. I didn't identify the exact hotspots but some of the suspected inefficiencies have been noted in the comments. It would also be beneficial to have the locking mechanism in the backend changed from being recursive to being iterative to prevent StackLevel errors. It didn't fail for me with 100 work packages though. The frontend prevented me from trying out moving 500. Whatever limit we enforce, it should be communicated to the user as quickly as possible. With CTRL-A, it is quite easy to select a large number of work packages.

Please find more detailed feedback within the code comments.

Edit:

I initially forgot to mention one other topic:

  • When dragging multiple items to an external application behaves in unexpected ways. Only the element actually dragged is dropped into an external application. This is understandable as we mimic the multi drag by tagging the other elements along on top of the HTML5 dragged element. Given that we cannot support dragging multiple work packages outside of the application, should we disable this visibly?

Comment thread frontend/AGENTS.md Outdated
- `./src/stimulus/` - Stimulus controllers
- `./src/turbo/` - Turbo integration
- `sortable-lists` batch selection is opt-in: a root enables it with a `selectionEnabled` value, and no other consumer's behavior changes. A root also sets `announcementScope`, so the shared controller's announcements speak the consumer's vocabulary instead of "item", and `selectionDescriptionId`, pointing at one shared element every selected card references via `aria-describedby`. Items declare `mobility` — `fixed`, `confined` or `free` — which gates dragging, selection eligibility, and positional moves alike. A missing value means `free`, so a consumer that renders none keeps working; an unrecognised one falls closed to `fixed` rather than handing the user controls the server will refuse. The pure selection model lives in `./src/common/batch-selection.ts` (framework-agnostic, so Angular consumers can adopt it); the DOM-facing adapter is `sortable-lists/selection.ts`, and gesture interpretation sits behind `sortable-lists/selection-orchestrator.ts`, which takes a narrow host port and imports no Stimulus. Selection identity is `(type, id)`, never the id alone: ids are unique per source table, so a nested list of another type can hold a colliding one. A root must render exactly one instance of each `(type, id)`, and an item declaring no type is refused as a candidate. A batch holds one item type — that cohort rule is orchestrator policy, not a constraint of the model, since identity namespacing and batch compatibility are different concerns. Ranges and select-all (Ctrl/Cmd+A) are both confined to the focused card's list; selecting across lists is a deliberate gap, reserved for a separate mechanism. An item belongs to its nearest ancestor root, so an independently nested root is an ownership boundary. Batch movement is not implemented: a drag still moves one card and collapses any wider selection onto it — that's a later work package.
- `sortable-lists` batch selection is opt-in: a root enables it with a `selectionEnabled` value, and no other consumer's behavior changes. A root also sets `announcementScope`, so the shared controller's announcements speak the consumer's vocabulary instead of "item", and `selectionDescriptionId`, pointing at one shared element every selected card references via `aria-describedby`. Items declare `mobility` — `fixed`, `confined` or `free` — which gates dragging, selection eligibility, and positional moves alike. A missing value means `free`, so a consumer that renders none keeps working; an unrecognised one falls closed to `fixed` rather than handing the user controls the server will refuse. The pure selection model lives in `./src/common/batch-selection.ts` (framework-agnostic, so Angular consumers can adopt it); the DOM-facing adapter is `sortable-lists/selection.ts`, and gesture interpretation sits behind `sortable-lists/selection-orchestrator.ts`, which takes a narrow host port and imports no Stimulus. Selection identity is `(type, id)`, never the id alone: ids are unique per source table, so a nested list of another type can hold a colliding one. A root must render exactly one instance of each `(type, id)`, and an item declaring no type is refused as a candidate. A batch holds one item type — that cohort rule is orchestrator policy, not a constraint of the model, since identity namespacing and batch compatibility are different concerns. Ranges and select-all (Ctrl/Cmd+A) are both confined to the focused card's list; selecting across lists is a deliberate gap, reserved for a separate mechanism. An item belongs to its nearest ancestor root, so an independently nested root is an ownership boundary. Dragging a selected card moves the whole batch: the root freezes the drag's batch at drag start (`beginDragBatch`), and a selection-enabled root with a `collectionMoveUrl` value submits ordered `ids[]` to the collection move action — for one dragged card or many. Dragging an unselected card still collapses any wider selection onto it. A root's `moveAnnouncementScope` value keys the move announcements the same way `announcementScope` keys the selection ones.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my comment on the previous PR, I'd still move this into the sortable lists directory

// Confined when the item is, or when any batch-mate the drag would carry
// is. Members are same-list by construction, so one confined member pins
// the whole block to the list they all sit in.
dragConfined(itemElement:HTMLElement):boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not correct. Batches can in fact span multiple list. Preliminary tests suggest, that this is behaving in a somewhat predictable manner so I wouldn't remove that possibility.

But the limitations are not correctly applied in some cases:

  • Have a read only item in one container
  • Have a writable item in another container
  • Within the container of the writable item have more items
  • Select the read only item first
  • Then select the writable item
  • Move the item within the container of the writable item

=> It looks as if the work packages can be moved inside the writable item's container.

Image

In this case, the error message could also be better, stating errors per work package.

When dragging two read-only work packages from two different containers together with a writable one from a third container should find no valid drop target at all. Currently, it does - the third container.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it would be preemptive to already think about boards where one of the challenges will be to prevent a work package being dragged into a container (because the status is not in the workflow). This might then be used to prevent work packages being moved out of their container on read-only as well. This would change the paradigm from "the work package cannot be moved out of its container" to "Those are the containers the work packages can be moved into - currently the list of containers is empty".

&& resolveItemType(item.element) === type
));

return outlet && outlet.element instanceof HTMLElement ? outlet.element : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is rather inefficient. It iterates over all outlets and then uses those to find a single element. Ultimately, it is not interested in the outlet but only the element of it. An implementation similar to orderedItemElements(root) from selection.ts would be more efficient.

This is relevant as the method is called repeatedly when dragging for each dragged card and triggered by multiple callbacks: getInitialData, onGenerateDragPreview, onDragStart, handleDrop.

From a performance perspective, the best solution would be to have a map to lookup in constant time. That map should not be persisted as to not become stale on morphing the DOM, but it could be recreated by callback and then passed on. If that is used, itemElementFor could be remove altogether which would be a good side effect as it also is somewhat alien in this controller.

Comment on lines +90 to +93
WorkPackage.transaction do
with_ordered_locks(lock_entries(placement.anchor)) do
revalidate_cohort!
revalidate_target_availability!(target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, while I acknowledge that the container might be changed mid-flight, I find it highly unlikely that it will. Additionally, what will happen if it does:

  • container deleted -> rollback of all work packages (wp contract catches)
  • container closed -> rollback of all work packages (wp contract catches)
  • container renamed -> nothing

Maybe I am missing a case but as of now, I don't see a reason for a lock.

itemIds?.forEach((id) => data.append('ids[]', id));
data.append('list_type', type);
data.append('list_id', listId ?? '');
data.append('prev_id', previousItemId ?? '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this fallback, prev_id will never be null. Therefore, the :append method will never be used. Haven't checked yet if that is a problem.

)
end

ordered

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the checks into a contract might also help with this method having unexpected side effects. It is called load_collection_work_packages but depending on whether there is an error or not, it either returns the work packages or not with the side effect of errors being rendered.

end

def collection_move_service_params
move_collection_params.to_h.symbolize_keys.except(:ids).compact

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is called a couple of times during a single request. This could be memoized. Not sure if it will have a big effect so feel free to disregard.

Comment on lines +90 to +93
WorkPackage.transaction do
with_ordered_locks(lock_entries(placement.anchor)) do
revalidate_cohort!
revalidate_target_availability!(target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought about this again for a bit. Maybe it is a good idea to acquire a lock for the container. With that in place, would have the benefit of knowing that the container does not change. Since it in effect also blocks parallel batch drops to the list, it should also reduce the likelihood of the positions column becoming corrupted.

render_invisible_after_move_batch_flash(call.result)
else
render_error_flash_message_via_turbo_stream(
message: I18n.t(:notice_unsuccessful_update_with_reason, reason: call.message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a batch of work packages is moved, the error displayed is not very helpful to know which one errored:

Image

There is no reference to the work package that causes this.

This might be improved by the proposed merging on ServiceResults and then using that information to print the error per work package. We do something similar printouts on bulk editing work packages and when copying projects, I think.

@myabc
myabc force-pushed the implementation/AGILE-278-batch-move branch from 0a61ca8 to 09d345a Compare September 4, 2026 10:06
@myabc
myabc force-pushed the implementation/AGILE-278-batch-move branch from 4353a15 to 8df3314 Compare September 5, 2026 11:29
@myabc
myabc force-pushed the implementation/AGILE-278-batch-move branch from 8df3314 to a344784 Compare September 5, 2026 16:25
myabc added 18 commits September 7, 2026 18:52
Adds PUT projects/:id/backlogs/work_packages/move taking ordered
ids and a three-state prev_id (after an anchor, top, or append). A
batch service chains each member after the previously moved one
inside one outer transaction, acquires advisory locks in ascending
id order, and revalidates the anchor and the project cohort under
lock, so a batch commits as one contiguous block or not at all and
after-commit hooks only ever observe the completed batch. The
optimistic response skips the frame reload only once the persisted
rows verifiably form the requested block, and the moved event
carries the ordered ids.

https://community.openproject.org/wp/AGILE-278
Dragging a selected card now moves the whole selection: the root
freezes the batch at drag start, resolves the drop against the
excluded selected ids so no member can anchor its own insertion,
reorders every row as one block, and submits ordered ids to a
root-configured collection URL for one card or many. Every
represented row carries the dragging treatment and batches show a
count on the preview, which renders before drag start and so
freezes the batch itself. Success clears the selection, an
unverifiable rollback warns even when the server flash stays
silent, and move announcements speak the consumer's vocabulary
through a dedicated scope value.

https://community.openproject.org/wp/AGILE-278
Iterates the ordered work_package_ids from the collection event
and falls back to the singular field, because no single scalar can
name whichever batch member the split view has open.

https://community.openproject.org/wp/AGILE-278
Adds the collection move URL and announcement scope values to the
Backlogs root, with work-package wording for every announcement
the shared controller can speak.

https://community.openproject.org/wp/AGILE-278
Merges the singular and plural invisible-after-move notices into one
count-pluralized key and converts the batch announcement keys to
i18n-js plural hashes, so locales with richer plural rules than
English can translate every form.
Proves the ordered cross-list block, the collapse-and-move-alone path,
atomic rejection with preserved selection, and the reload- free
optimistic same-list reorder against a real browser and database.

https://community.openproject.org/wp/AGILE-278
View-only users had no synchronous response to a click or Enter until
the frame visit landed, since the previous feedback relied on the
permission-gated batch selection. Adds a visual-only mark.
A count in the corner was the only sign that a drag carried more than
one card. Ghost layers behind the preview make the batch legible at a
glance, composed into the card's own lift shadow so both survive.

https://community.openproject.org/wp/AGILE-396
The stack drew two ghost layers whatever the batch, so a pair of cards
and a dozen read alike. Its depth now follows the batch up to four
cards, leaving the badge to carry the exact count. The overhang stays
reserved at the deepest stack so the badge keeps one offset.
Both preview.ts and the stylesheet carried the overhang widths and the
layer cap, held in step by comments alone, so retuning the stack would
have left the overhang uncontained. Sass now publishes the widths as
custom properties and holds any deeper batch at the maximum itself.
The selection orchestrator honours only the platform's own multi-select
modifier now, and the suites pin the platform to Windows, so a Meta-
modified click there is an ordinary click that collapses the selection.
Drives the batch-move examples through Ctrl, as the selection specs do.
Takes one advisory lock per container before the batch runs, so two
concurrent moves cannot interleave their placements, and wraps the batch
in its own savepoint: joined into an enclosing transaction, a rollback
would otherwise be swallowed and half the batch would commit. Reports
the member that refused through the result's dependent errors rather
than a bespoke failure object.
Moves the shape of a batch move request behind a params contract the
service consults itself, so a caller reaching past the controller gets
the same refusal, and keeps the contract's errors off the project it
validates against.
Gives the orchestrator one action-scope API in place of four
gesture-named collapse methods, and splits the drag lifecycle so the
batch is frozen once in the preview callback and its rows marked at drag
start. A drag now always selects the card it carries; a menu move
collapses the batch onto the card it names instead, and speaks only when
a wider batch really collapsed.
Resolves each batch member's permitted destinations once at drag start
and gates every drop on their intersection, so a batch carrying a
confined member can only land where that member may go. Refuses a drag
whose batch exceeds the server's cap before it starts, with an assertive
announcement, rather than letting it fail on the drop.

Destinations are frozen as identities, not list elements: a morph that
replaces a permitted list mid-drag would otherwise leave the payload
naming a detached node.
Reduces a drop target's payload to the identity a drop actually reads,
so the batch-aware fields are computed once for the dragged source
instead of on every dragover, and hands an external drop every member of
the batch rather than the one card under the pointer.
Pairs the pressed-state writes with the aria-current helpers they mirror,
and adds feature coverage for a batch dropped into an empty list, at the
top of a list, selected with Ctrl/Cmd+A, and behind the inbox's
truncation fold.
Every item drop target resolves its owner list on each dragover by
scanning the list outlets. The owner cannot change while a drag is in
flight, so the root now remembers it from drag start to drop, alongside
the frozen batch.
@myabc
myabc force-pushed the implementation/AGILE-278-batch-move branch from a344784 to 429e9f9 Compare September 7, 2026 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature javascript Pull requests that update Javascript code needs review

Development

Successfully merging this pull request may close these issues.

4 participants