[DREAM-786] Extend sortable-lists for admin surfaces and migrate project attributes - #24504
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the Admin → Settings → Project custom fields UI to use the sortable-lists Stimulus subsystem (including nested lists), adds anchor-based drop endpoints for reordering/moving fields and sections, and switches the Turbo Stream updates for the settings components to method="morph" to reconcile server-rendered order.
Changes:
- Backend: add anchor-based reordering (
list_id/prev_id) for project custom fields and sections, including a reusableLists::MoveAfterAnchorconcern. - Frontend: extend
sortable-listsroot to support nested list topologies, per-item-type move URL templates, and an opt-inoptimisticrequest flag (kept for Backlogs). - Tests: add/extend model, service, request, and component specs covering reorder/move success and “no mutation on invalid anchor” cases.
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 |
|---|---|
| app/services/custom_fields/drop_service.rb | Adds anchor-based (list_id/prev_id) move logic with transactional section updates. |
| app/controllers/admin/settings/project_custom_fields_controller.rb | Switches drop endpoint to sortable-lists params + validates request; returns 422 on invalid anchors. |
| app/controllers/admin/settings/project_custom_field_sections_controller.rb | Replaces position-based drop with anchor-based move_after_anchor. |
| app/models/concerns/lists/move_after_anchor.rb | New concern to reorder acts_as_list models relative to an anchor id. |
| app/models/custom_field_section.rb | Includes MoveAfterAnchor and adds insert_after_key for per-section attribute ordering. |
| app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb | Uses Turbo Stream method: :morph for section(s) updates. |
| app/components/settings/project_custom_field_sections/index_component.rb | Wires sortable-lists root with per-type move URL templates and outlets. |
| app/components/settings/project_custom_field_sections/index_component.html.erb | Replaces dragula/generic-dnd wiring with sortable-lists list + items. |
| app/components/settings/project_custom_field_sections/show_component.rb | Wires each section’s field list and field rows for sortable-lists. |
| app/components/settings/project_custom_field_sections/show_component.html.erb | Adds handle target wiring and sortable-lists item/list data attributes. |
| app/components/settings/project_custom_field_sections/custom_field_row_component.rb | Adds stable wrapper identity for morphing. |
| app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb | Wires the drag handle as sortable-lists item handle target. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts | Adds nested-owner list resolution, per-type URL mapping, and optimistic flag gating. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts | Adds coverage for nested topology, URL template map, and optimistic behavior. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts | Uses root-resolved rows container for sticky bounds (nested DOM support). |
| frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts | Tests root-resolved rows container behavior. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts | Adds resolveItemType helper and exported attribute constant. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts | Adds unit tests for resolveItemType. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts | Extends SortableListsRoot contract with ownerRowsContainer. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts | Updates stubs to satisfy the extended root contract. |
| frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts | Updates stubs to satisfy the extended root contract. |
| modules/backlogs/app/views/backlogs/backlog/show.html.erb | Opts Backlogs into optimistic move requests via data attribute. |
| config/locales/en.yml | Adds error_invalid_list_move_anchor message. |
| spec/services/custom_fields/drop_service_spec.rb | Adds service-level coverage for anchor-based reorders and rollback behavior. |
| spec/models/project_custom_field_section_spec.rb | Adds unit coverage for insert_after_key. |
| spec/models/concerns/lists/move_after_anchor_spec.rb | Adds unit coverage for MoveAfterAnchor. |
| spec/requests/admin/settings/project_custom_fields_drop_spec.rb | Adds request specs for custom field drop behavior + authorization. |
| spec/requests/admin/settings/project_custom_field_sections_drop_spec.rb | Adds request specs for section drop behavior and morph response. |
| spec/components/settings/project_custom_field_sections/index_component_spec.rb | Verifies sortable-lists root wiring and absence of dragula remnants. |
| spec/components/settings/project_custom_field_sections/show_component_spec.rb | Verifies sortable-lists wiring for nested field lists and handle target. |
| spec/components/settings/project_custom_field_sections/custom_field_row_component_spec.rb | Verifies stable row keys and handle wiring. |
d4cb4b4 to
707d2f1
Compare
|
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. |
| # | ||
| # Returns false without mutating when the anchor is unknown, outside | ||
| # the scope, or the record itself. | ||
| def move_after_anchor(prev_id, scope:) # rubocop:disable Naming/PredicateMethod -- verb command, not a query |
There was a problem hiding this comment.
Instead of implementing a new move_after method, I would prefer reusing the existing move_after method from:
openproject/modules/backlogs/lib/open_project/backlogs/list.rb
Lines 45 to 62 in a9f0bda
There was a problem hiding this comment.
@dombesz I considered this, but the Backlogs move_after has semantics this endpoint can't adopt: it mutates first (remove_from_list) and treats an unknown prev_id as "move to top", while these drop endpoints must reject an invalid or stale anchor with 422 and no mutation — that guarantee is also what keeps the cross-section move race-safe under the section row locks.
It's also coupled to the Backlogs domain (list scopes, update_columns position override), so it isn't reusable as-is.
So I would actually propose we do the opposite: keep Lists::MoveAfterAnchor as the canonical concern and then migrate Backlogs back onto it (albeit in a follow-up). It would also give us a chance to reevaluate its lenient fallback and support for the legacy position: param deserve their own consideration). Happy to create a work package.
There was a problem hiding this comment.
e5c2971 to
58b8108
Compare
58b8108 to
9118b5a
Compare
Replaces the drag path's closest('li') source-row lookup and the item's
parentElement assumption with one invariant: an item's owning list is
the innermost list outlet containing it. Rows now work as any element,
which admin BorderBox rows and table rows require.
https://community.openproject.org/wp/DREAM-786
Adds a type-to-template map on the root so one root can host item types with different move endpoints, falling back to the single template. Menu moves read the type off the item element, so map-only roots serve the directional path too. https://community.openproject.org/wp/DREAM-786
The optimistic=true query flag suppresses the server's frame reload and belongs to Backlogs' event-only success contract. Admin surfaces reconcile through morph streams instead, so the root now sends the flag only when a consumer declares it; Backlogs opts in. https://community.openproject.org/wp/DREAM-786
Sortable-list moves arrive as a prev_id anchor rather than an absolute position. Adds the shared move-after-anchor primitive with strict validation: blank means top, and an unknown, out-of-scope, or self anchor is rejected without mutation. https://community.openproject.org/wp/DREAM-786
Custom fields inside a section are ordered by the attribute_order array rather than acts_as_list. Adds the anchor-based counterpart to add_to_order with the same strict semantics: blank anchors the head, an unresolvable anchor is rejected without mutation. https://community.openproject.org/wp/DREAM-786
Accepts the sortable-lists list_id/prev_id wire alongside the legacy target_id/position wire, which the user custom fields surface still sends until its own migration. Anchor moves validate before mutating and lock both section rows in id order against concurrent reorders. Reuses the single locked section object for the same-row case so every read and write goes through it, closing a lost-update window where a fresh, unlocked lookup of the target section could be read from and written to before the lock took effect. Runs the anchor move in a savepoint transaction so its rollback guard undoes a partially applied cross-section reparent even when nested inside a caller's own transaction. https://community.openproject.org/wp/DREAM-786
Converts both project-attribute drop endpoints from absolute positions to the sortable-lists list_type/list_id/prev_id wire with a strict contract: exact list type, list id only where a section is addressed, and a required scalar prev_id filtered through permit, so a collection id cannot reach the anchor lookup as an IN match. Violations and rejected anchors answer 422 without mutation. https://community.openproject.org/wp/DREAM-786
The optimistic client reorder must survive the server response, so the section update and sections replace streams switch to morph. Field row wrappers gain a per-field id: with every row sharing one wrapper id the morph matcher can associate the wrong rows on reorder. https://community.openproject.org/wp/DREAM-786
Replaces the page's Dragula wiring with the shared suite: one root with per-type move URL templates, the sections flex container and each BorderBox as typed lists, sections and fields as typed items with their existing drag handles. Server order reconciles through morph streams instead of the optimistic flag. https://community.openproject.org/wp/DREAM-786
The row lookup climbed ancestors unbounded, so an empty section's placeholder row resolved the enclosing section as its anchor instead of resolving to none. Dropping the first field into an empty section then sent the section's id as prev_id, and the server rejected it as an invalid anchor. Bounds the ancestor search to the row's own rows container, and adds a nested-topology regression test. https://community.openproject.org/wp/DREAM-786
Adds the Selenium feature spec for drag and menu reorders of sections and fields, including cross-section, empty-section, and post-morph drags. frontend/CONTEXT.md is not touched: it was deliberately removed from the tree pending a team decision (11d9a5c) that has not since been revisited, so the consumer-contract notes the task called for stay out of scope here. https://community.openproject.org/wp/DREAM-786
The optimistic anchor lookup queried all descendants, so an item in a nested inner list could shadow the intended anchor when ids from different tables collide. Matches the anchor against the target list's direct rows through the bounded row resolvers instead. https://community.openproject.org/wp/DREAM-786
Section rows previously fell back to the browser's native drag snapshot, which paints the box's top margin as whitespace and squares off the rounded corners on a white backing. Marks the BorderBox as the item's preview target and zeroes the clone's margin so spacing utilities never bleed into the preview.
Primer's DragHandle ships cursor: move. Scopes a grab/grabbing override to sortable-lists handles until the DragHandle itself adopts it upstream.
75afce9 to
cef98a4
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (1)
spec/services/custom_fields/drop_service_spec.rb:148
- Typo in comment: “un-reachable-in-practice” should be “unreachable-in-practice” (or similar).
Ticket
https://community.openproject.org/wp/DREAM-786
What are you trying to accomplish?
Extends the shared
sortable-listsStimulus suite so admin surfaces can adopt it, and proves the extended contract by migrating Administration → Projects → Project attributes (sections + fields) off Dragula (generic-drag-and-drop).closest('li')assumption); a per-type move-URL template map so one root can host item types with different endpoints; theoptimistic=trueflag is now opt-in (Backlogs opts in to keep its event-only success contract, admin surfaces reconcile through morph streams).prev_id) ordering primitives with strict validation — a blank anchor means top; an unknown, out-of-scope, self, or omitted anchor is a 422 with zero mutation. Both project-attributedropendpoints convert in-place to thelist_type/list_id/prev_idwire (the page migrates in the same PR, so no dual-wire period).method="morph"turbo streams; field rows gain unique per-field wrapper IDs so the morph matcher associates rows correctly on reorder.prev_idanchor; the lookup is now bounded to the row's own list.Note
Visually this is more or less a like-for-like migration. The newer Backlogs-style styling improvements (e.g. drop indicators) land when this page migrates to
BorderBoxListComponent: https://community.openproject.org/wp/DREAM-697Covered by vitest suite specs (nested dual-role topology), model/service/request/component specs, and a Selenium feature spec (same-section, cross-section and empty-section drags, menu moves, drag-after-morph).
What approach did you choose and why?
Lists::MoveAfterAnchor(foracts_as_listmodels) andCustomFieldSection#insert_after_key(for theattribute_orderarray) live next to the state they mutate.ProjectCustomFieldSections::UpdateServiceis no longer in the drop path (it carried no validation or side effects relevant to positioning).CustomFields::DropServicekeeps a legacy adapter path. The user-custom-fields surface still sends the oldtarget_id/positionwire; the path is pinned by a regression spec and dies with https://community.openproject.org/wp/DREAM-790.attribute_order(a read-modify-write array). Theacts_as_listside needs no equivalent lock — position shifts are single SQL statements, so the worst concurrent case is a surprising order, not corruption.__id__→{id}), never hardcoded paths, so relative-URL-root installations keep working.move_tomenu actions andCustomFields::MoveServiceare untouched (shared four-direction menu descriptor is https://community.openproject.org/wp/DREAM-775).Screen recording
Screen.Recording.2026-08-06.at.13.34.24.mov
Follow-up notes:
sortable-lists--scrollableneeds a scroll-container decision.selenium_target_point, honey-pot cleanup) are duplicated between the backlogs and admin specs — worth extracting when the next surface migrates.Merge checklist