Skip to content

Commit f055c63

Browse files
authored
Merge pull request #24504 from opf/implementation/DREAM-786-sortable-lists-admin-project-attributes
[DREAM-786] Extend sortable-lists for admin surfaces and migrate project attributes
2 parents 29ee8a6 + cef98a4 commit f055c63

35 files changed

Lines changed: 1742 additions & 96 deletions

app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
flex_layout(justify_content: :space_between, align_items: :center) do |main_container|
44
main_container.with_column(flex_layout: true, align_items: :center) do |content_container|
55
content_container.with_column(mr: 2) do
6-
render(Primer::OpenProject::DragHandle.new(classes: "handle"))
6+
render(Primer::OpenProject::DragHandle.new(classes: "handle", data: { sortable_lists__item_target: "handle" }))
77
end
88
content_container.with_column(mr: 2) do
99
render(

app/components/settings/project_custom_field_sections/custom_field_row_component.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def initialize(project_custom_field:, first:, last:)
4545

4646
private
4747

48+
def wrapper_uniq_by
49+
@project_custom_field.id
50+
end
51+
4852
def edit_action_item(menu)
4953
menu.with_item(label: t("label_edit"),
5054
href: edit_admin_settings_project_custom_field_path(@project_custom_field),

app/components/settings/project_custom_field_sections/index_component.html.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<%=
22
component_wrapper(data: wrapper_data_attributes) do
33
if @project_custom_field_sections.any?
4-
flex_layout(classes: "dragula-container", data: { "allowed-drop-type": "section" }.merge(drop_target_config)) do |flex|
4+
flex_layout(data: sections_list_data) do |flex|
55
@project_custom_field_sections.each do |section|
66
flex.with_row(
7-
data: draggable_item_config(section)
7+
data: section_item_data(section)
88
) do
99
render(row_component_class.new(project_custom_field_section: section, first_and_last:))
1010
end

app/components/settings/project_custom_field_sections/index_component.rb

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,37 @@ def first_and_last
5353

5454
def wrapper_data_attributes
5555
{
56-
controller: "generic-drag-and-drop"
56+
controller: "sortable-lists",
57+
sortable_lists_move_url_templates_value: move_url_templates.to_json,
58+
sortable_lists_sortable_lists__list_outlet: "##{wrapper_key} [data-controller~='sortable-lists--list']",
59+
sortable_lists_sortable_lists__item_outlet: "##{wrapper_key} [data-controller~='sortable-lists--item']"
5760
}
5861
end
5962

60-
def drop_target_config
63+
# Built from route helpers with a sentinel so relative-URL-root
64+
# installations keep working; {id} is expanded client-side.
65+
def move_url_templates
66+
id_placeholder = "__id__"
6167
{
62-
generic_drag_and_drop_target: "container",
63-
"target-allowed-drag-type": "section" # the type of dragged items which are allowed to be dropped in this target
68+
section: drop_admin_settings_project_custom_field_section_path(id_placeholder).sub(id_placeholder, "{id}"),
69+
custom_field: drop_admin_settings_project_custom_field_path(id_placeholder).sub(id_placeholder, "{id}")
6470
}
6571
end
6672

67-
def draggable_item_config(section)
73+
def sections_list_data
6874
{
69-
"draggable-id": section.id,
70-
"draggable-type": "section",
71-
"drop-url": drop_admin_settings_project_custom_field_section_path(section)
75+
controller: "sortable-lists--list",
76+
sortable_lists__list_type_value: "section",
77+
sortable_lists__list_accepted_type_value: "section"
78+
}
79+
end
80+
81+
def section_item_data(section)
82+
{
83+
controller: "sortable-lists--item",
84+
sortable_lists__item_id_value: section.id,
85+
sortable_lists__item_type_value: "section",
86+
sortable_lists__item_label_value: section.name
7287
}
7388
end
7489
end

app/components/settings/project_custom_field_sections/show_component.html.erb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<%=
22
component_wrapper(class: "op-project-custom-field-section-container", data: { test_selector: "project-custom-field-section-container-#{@project_custom_field_section.id}" }) do
3-
render(border_box_container(mt: 3, data: drag_and_drop_target_config)) do |component|
3+
render(border_box_container(mt: 3, data: field_list_data)) do |component|
44
component.with_header(font_weight: :bold) do
55
flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container|
66
section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container|
77
content_container.with_column(mr: 2) do
8-
render(Primer::OpenProject::DragHandle.new(classes: "handle"))
8+
render(Primer::OpenProject::DragHandle.new(classes: "handle", data: { sortable_lists__item_target: "handle" }))
99
end
1010
content_container.with_column do
1111
render(Primer::Beta::Text.new(font_weight: :bold)) do
@@ -120,7 +120,7 @@
120120
end
121121
else
122122
@ordered_cfs.each_with_index do |cf, index|
123-
component.with_row(data: draggable_item_config(cf)) do
123+
component.with_row(data: field_item_data(cf)) do
124124
render(
125125
custom_field_row_component_class.new(
126126
project_custom_field: cf,

app/components/settings/project_custom_field_sections/show_component.rb

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,26 @@ def wrapper_uniq_by
5454
@project_custom_field_section.id
5555
end
5656

57-
def drag_and_drop_target_config
57+
def field_list_data
5858
{
59-
generic_drag_and_drop_target: "container",
60-
"target-container-accessor": ".Box > ul",
61-
"target-id": @project_custom_field_section.id,
62-
"target-allowed-drag-type": "custom-field"
59+
controller: "sortable-lists--list",
60+
sortable_lists__list_type_value: "custom_field",
61+
sortable_lists__list_accepted_type_value: "custom_field",
62+
sortable_lists__list_id_value: @project_custom_field_section.id,
63+
sortable_lists__list_name_value: @project_custom_field_section.name,
64+
# The section's drag preview snapshots the box itself rather than
65+
# the browser's native capture of the row wrapper, which paints the
66+
# box's top margin and squares off the rounded corners.
67+
sortable_lists__item_target: "preview"
6368
}
6469
end
6570

66-
def draggable_item_config(project_custom_field)
71+
def field_item_data(project_custom_field)
6772
{
68-
"draggable-id": project_custom_field.id,
69-
"draggable-type": "custom-field",
70-
"drop-url": drop_admin_settings_project_custom_field_path(project_custom_field)
73+
controller: "sortable-lists--item",
74+
sortable_lists__item_id_value: project_custom_field.id,
75+
sortable_lists__item_type_value: "custom_field",
76+
sortable_lists__item_label_value: project_custom_field.name
7177
}
7278
end
7379

app/controllers/admin/settings/project_custom_field_sections_controller.rb

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,17 @@ def move
9595
end
9696

9797
def drop
98-
call = ::ProjectCustomFieldSections::UpdateService.new(user: current_user, model: @project_custom_field_section).call(
99-
position: params[:position].to_i
100-
)
98+
moved = valid_drop_request? &&
99+
@project_custom_field_section.move_after_anchor(drop_params[:prev_id], scope: ProjectCustomFieldSection.all)
101100

102-
if call.success?
101+
if moved
103102
update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?)
104103
update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all)
104+
respond_with_turbo_streams
105105
else
106-
render_section_error_via_turbo_stream(call)
106+
render_error_flash_message_via_turbo_stream(message: I18n.t(:error_invalid_list_move_anchor))
107+
respond_with_turbo_streams(status: :unprocessable_entity)
107108
end
108-
respond_with_turbo_streams
109109
end
110110

111111
def new_link
@@ -126,6 +126,26 @@ def set_project_custom_field_section
126126
@project_custom_field_section = ProjectCustomFieldSection.find(params[:id])
127127
end
128128

129+
# The sortable-lists wire for the one global sections list: the type must
130+
# match and no list id may be addressed. prev_id must be present as a
131+
# scalar parameter (blank means top): an accidentally omitted anchor
132+
# cannot read as a move-to-top request, and a collection-valued id
133+
# (prev_id[]=...) cannot reach the anchor lookup as an IN list. The
134+
# raw list_id check stays deliberately: permit cannot distinguish an
135+
# absent list_id from a filtered-out collection one, and both a
136+
# nonblank and a collection value are contract violations here.
137+
def valid_drop_request?
138+
drop_params[:list_type] == "section" &&
139+
params[:list_id].blank? &&
140+
drop_params.key?(:prev_id)
141+
end
142+
143+
# permit's scalar filter drops collection-valued parameters, so a
144+
# missing and a non-scalar prev_id both fail the key check above.
145+
def drop_params
146+
@drop_params ||= params.permit(:list_type, :list_id, :prev_id)
147+
end
148+
129149
def allow_custom_field_creation?
130150
ProjectCustomFieldSection.any?
131151
end

app/controllers/admin/settings/project_custom_fields_controller.rb

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,20 +159,20 @@ def move
159159
end
160160

161161
def drop
162+
return render_invalid_drop_request unless valid_drop_request?
163+
162164
result = CustomFields::DropService.new(user: current_user, custom_field: @custom_field).call(
163-
target_id: params[:target_id],
164-
position: params[:position]
165+
list_id: drop_params[:list_id],
166+
prev_id: drop_params[:prev_id]
165167
)
166168

167169
if result.success?
168170
drop_success_streams(result)
171+
respond_with_turbo_streams
169172
else
170-
render_error_flash_message_via_turbo_stream(
171-
message: join_flash_messages(result.errors)
172-
)
173+
render_error_flash_message_via_turbo_stream(message: join_flash_messages(result.errors))
174+
respond_with_turbo_streams(status: :unprocessable_entity)
173175
end
174-
175-
respond_with_turbo_streams
176176
end
177177

178178
def destroy
@@ -265,6 +265,26 @@ def find_custom_field
265265
@custom_field = ProjectCustomField.find(params.expect(:id))
266266
end
267267

268+
# Ids must be scalar strings: a collection-valued list_id would pick an
269+
# arbitrary target section out of an IN lookup, and a collection-valued
270+
# prev_id would 500 instead of answering the promised 422. permit's
271+
# scalar filter drops collection values, so the presence checks below
272+
# reject them alongside genuinely missing parameters.
273+
def valid_drop_request?
274+
drop_params[:list_type] == "custom_field" &&
275+
drop_params[:list_id].present? &&
276+
drop_params.key?(:prev_id)
277+
end
278+
279+
def drop_params
280+
@drop_params ||= params.permit(:list_type, :list_id, :prev_id)
281+
end
282+
283+
def render_invalid_drop_request
284+
render_error_flash_message_via_turbo_stream(message: I18n.t(:error_invalid_list_move_anchor))
285+
respond_with_turbo_streams(status: :unprocessable_entity)
286+
end
287+
268288
def drop_success_streams(call)
269289
update_section_via_turbo_stream(project_custom_field_section: call.result[:current_section])
270290
if call.result[:section_changed]

app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def update_section_via_turbo_stream(project_custom_field_section:)
5050
# a single custom field section, and not a list of sections. Calling first?
5151
# and last? method in the component will not result in an N+1 in this case.
5252
project_custom_field_section:
53-
)
53+
),
54+
method: :morph
5455
)
5556
end
5657

@@ -66,7 +67,8 @@ def update_sections_via_turbo_stream(project_custom_field_sections:)
6667
replace_via_turbo_stream(
6768
component: ::Settings::ProjectCustomFieldSections::IndexComponent.new(
6869
project_custom_field_sections:
69-
)
70+
),
71+
method: :morph
7072
)
7173
end
7274
end
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module Lists
32+
# Anchor-based reordering for acts_as_list models: moves the record
33+
# directly below another record of the same list, addressed by id.
34+
module MoveAfterAnchor
35+
# Moves the record below the record identified by `prev_id` within
36+
# `scope` (a relation over the same acts_as_list list). A blank
37+
# `prev_id` moves the record to the top.
38+
#
39+
# Returns false without mutating when the anchor is unknown, outside
40+
# the scope, or the record itself.
41+
def move_after_anchor(prev_id, scope:) # rubocop:disable Naming/PredicateMethod -- verb command, not a query
42+
if prev_id.blank?
43+
move_to_top
44+
return true
45+
end
46+
47+
anchor = scope.find_by(id: prev_id)
48+
return false if anchor.nil? || anchor.id == id
49+
50+
# Removing the record first shifts the anchor up by one when the
51+
# record currently sits above it, so the target slot differs by
52+
# direction of travel.
53+
insert_at(position > anchor.position ? anchor.position + 1 : anchor.position)
54+
true
55+
end
56+
end
57+
end

0 commit comments

Comments
 (0)