[AGILE-278] Move selected work packages atomically - #24778
Conversation
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
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
There was a problem hiding this comment.
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-sideBacklogs::WorkPackages::BatchUpdateServiceto move ordered batches atomically with advisory locks and revalidation under lock. - Updated
sortable-listsStimulus controllers to freeze the drag batch at drag start, submit orderedids[]to the collection endpoint, exclude selected ids when resolvingprev_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.
Deploying openproject with ⚡ PullPreview
|
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy 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. |
Reuses the Counter contract at the CSS level so the badge is stylesheet-targetable instead of carrying a one-off inline skin, rendered with lit-html per existing precedent. Firefox folds overflow into its drag-image snapshot and misaligns the grab offset, so the badge gets an inset, shadowless variant there. https://community.openproject.org/wp/AGILE-278
a31087a to
320b49b
Compare
Adds a target-availability check under the batch move's lock, since the contract's sprint_id/backlog_bucket_id change guard never trips for a same-list reorder, leaving a sprint that completed (or a bucket reassigned) after page load still silently accepted. https://community.openproject.org/wp/AGILE-278
A Turbo morph mid-drag can replace a batch-mate's row with a fresh element that never went through beginDragBatch, so it loses the dragging treatment while the rest of the frozen batch keeps it. The registration heal now re-applies the mark to every row the drag still represents. https://community.openproject.org/wp/AGILE-278
Recursing on entries.drop(1) allocated a fresh array per lock acquired, making the traversal quadratic in batch size. Recurses on an index into the same array instead. https://community.openproject.org/wp/AGILE-278
| drag_backlogs_item(source: moved_element, target: target_element, edge: :bottom) | ||
| end | ||
| rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError | ||
| retry |
| { "backlogs/work_packages": %i[move move_collection move_to_sprint_dialog move_to_bucket_dialog | ||
| add_existing_dialog add_existing] }, |
There was a problem hiding this comment.
nitpick: single line per item
| # prev_id → no anchor), :append (absent prev_id → the last non-batch | ||
| # member of the target, resolved NOW so it participates in the lock set | ||
| # and can be revalidated under lock; nil anchor means an empty target). | ||
| Placement = Struct.new(:mode, :anchor) do |
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy 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. |
Ticket
https://community.openproject.org/wp/AGILE-278
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 PR #24525.
PUT projects/:id/backlogs/work_packages/move) accepts orderedids[]with a three-stateprev_id(after an anchor, top, append) and rejects blanks, duplicates, unresolvable members, invalid targets, and stale or in-batch predecessors outright.Backlogs::WorkPackages::BatchUpdateServicechains 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.work_package_idsfrom the collection action while the member action keeps its scalar, and the split-view synchronizer consumes both.Screenshots
Visual changes (multi-row dragging treatment, batch count badge on the drag preview) have unit coverage but no design pass yet — screenshots to follow after a manual QA round.
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
UpdateServiceper member rather than reimplementing the move, so validation and journaling stay in one place; a realBatchFailureexception (neverActiveRecord::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 subtleacts_as_liststaleness bugs surfaced during review and are covered by regression tests: in-memory positions feedingremove_from_listthresholds 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
onDragStartfires. Keep-batch-on-menu-invocation, positional batch actions, and batch destination dialogs are follow-up slices (#AGILE-362, #AGILE-363, #AGILE-364).Merge checklist