[DREAM-786] Extend sortable-lists for admin surfaces and migrate project attributes - #24504
[DREAM-786] Extend sortable-lists for admin surfaces and migrate project attributes#24504myabc wants to merge 13 commits into
Conversation
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.
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.
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. |
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.
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.
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.
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 prev_id parameter. Violations and rejected anchors answer 422 without mutation.
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.
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.
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.
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.
d4cb4b4 to
707d2f1
Compare
Blankness and key checks let array or hash ids through to the anchor lookup, where a collection prev_id reordered against an arbitrary IN match on sections and raised on fields, breaking the uniform-422 contract. Filters the ids through permit, whose scalar filter drops collection values alongside missing ones.
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.
|
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
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, unblocks<tr>/BorderBox rows); a per-type move-URL template map on the root 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 instead).prev_id) ordering primitives with strict validation — blank anchor means top, an unknown/out-of-scope/self anchor is rejected with zero mutation.dropendpoints convert in-place to thelist_type/list_id/prev_idwire with a uniform 422 contract (an omittedprev_idparameter is a 422, never an implicit move-to-top).method="morph"turbo streams, and field rows gain unique per-field wrapper IDs so the morph matcher can associate rows correctly on reorder.prev_idanchor (server 422). The lookup is now bounded to the row's own list.Covered 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. The sections controller callsmove_after_anchordirectly —ProjectCustomFieldSections::UpdateServiceis no longer in the drop path (it carried no validation or side effects relevant to positioning; worth knowing if validation is ever added there).CustomFields::DropServicekeeps a legacy adapter path. The user-custom-fields surface still sends the oldtarget_id/positionwire until https://community.openproject.org/wp/DREAM-790, so the service accepts either wire; the legacy path is pinned by a regression spec and dies with DREAM-790.attribute_order(it is a read-modify-write array, so the lock prevents concurrent admins losing each other's updates). Theacts_as_listside has no equivalent lock — position shifts happen in 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).Follow-up notes:
sortable-lists--scrollableneeds a scroll-container decision.frontend/CONTEXT.mdwas removed repo-wide pending a wider discussion).selenium_target_point, honey-pot cleanup) are now duplicated between the backlogs and admin specs — worth extracting when the next surface migrates.Merge checklist