inline_forms 7.2.11 → 7.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +176 -0
- data/README.rdoc +4 -2
- data/app/assets/javascripts/inline_forms/inline_forms.js +23 -0
- data/app/assets/stylesheets/inline_forms/inline_forms.scss +32 -16
- data/app/controllers/concerns/versions_concern.rb +2 -1
- data/app/controllers/inline_forms_application_controller.rb +5 -1
- data/app/controllers/inline_forms_controller.rb +120 -24
- data/app/helpers/form_elements/ckeditor.rb +4 -30
- data/app/helpers/form_elements/plain_text.rb +23 -0
- data/app/helpers/form_elements/plain_text_area.rb +7 -3
- data/app/helpers/form_elements/text_area.rb +4 -44
- data/app/helpers/form_elements/text_area_without_ckeditor.rb +5 -4
- data/app/helpers/form_elements/text_field.rb +2 -2
- data/app/helpers/inline_forms_helper.rb +127 -71
- data/app/views/devise/sessions/_form.html.erb +4 -1
- data/app/views/inline_forms/_close.html.erb +6 -4
- data/app/views/inline_forms/_edit.html.erb +7 -40
- data/app/views/inline_forms/_list.html.erb +52 -39
- data/app/views/inline_forms/_new.html.erb +23 -11
- data/app/views/inline_forms/_show.html.erb +13 -11
- data/app/views/inline_forms/_versions_list.html.erb +4 -8
- data/app/views/inline_forms/create_list_frame.html.erb +3 -0
- data/app/views/inline_forms/field_edit.html.erb +3 -0
- data/app/views/inline_forms/field_show.html.erb +3 -0
- data/app/views/inline_forms/new_record.html.erb +3 -0
- data/app/views/inline_forms/row_close.html.erb +5 -0
- data/app/views/inline_forms/row_destroyed.html.erb +9 -0
- data/app/views/inline_forms/row_show.html.erb +3 -0
- data/app/views/inline_forms/versions_list_panel.html.erb +3 -0
- data/app/views/inline_forms/versions_panel.html.erb +3 -0
- data/app/views/layouts/application.html.erb +0 -1
- data/app/views/layouts/inline_forms.html.erb +10 -1
- data/bin/inline_forms +22 -1
- data/bin/inline_forms_installer_core.rb +38 -3
- data/docs/ujs-to-turbo.md +193 -0
- data/lib/generators/USAGE +2 -2
- data/lib/generators/assets/stylesheets/inline_forms.scss +32 -16
- data/lib/inline_forms/version.rb +1 -1
- data/lib/inline_forms.rb +58 -2
- data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_field_turbo_test.rb +74 -0
- data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_name_list_test.rb +73 -0
- data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_photos_pagination_test.rb +199 -15
- data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_row_turbo_test.rb +94 -0
- data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_top_level_new_test.rb +100 -0
- data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_versions_turbo_test.rb +27 -0
- data/lib/installer_templates/example_app_tests/test/models/example_app_plain_text_rich_text_edge_cases_test.rb +46 -0
- data/lib/installer_templates/example_app_views/apartments/name_list.html.erb +26 -0
- data/lib/installer_templates/example_app_views/inline_forms/_header.html.erb +45 -0
- data/test/inline_forms_generator_test.rb +10 -0
- data/test/plain_text_configuration_test.rb +90 -0
- metadata +21 -5
- data/app/views/inline_forms/edit.js.erb +0 -1
- data/app/views/inline_forms/show_element.js.erb +0 -1
- data/app/views/inline_forms/update.js.erb +0 -1
- data/lib/generators/assets/javascripts/ckeditor/config.js +0 -72
data/lib/inline_forms.rb
CHANGED
|
@@ -4,6 +4,8 @@ require_relative ('inline_forms/form_element_from_callee')
|
|
|
4
4
|
# InlineForms is a Rails Engine that let you setup an admin interface quick and
|
|
5
5
|
# easy. Please install it as a gem or include it in your Gemfile.
|
|
6
6
|
module InlineForms
|
|
7
|
+
class PlainTextColumnMissingError < StandardError; end
|
|
8
|
+
|
|
7
9
|
# DEFAULT_COLUMN_TYPES holds the standard ActiveRecord::Migration column types.
|
|
8
10
|
# This list provides compatability with the standard types, but we add our own
|
|
9
11
|
# later in 'Special Column Types'.
|
|
@@ -62,7 +64,7 @@ module InlineForms
|
|
|
62
64
|
#
|
|
63
65
|
DEFAULT_FORM_ELEMENTS = {
|
|
64
66
|
:string => :text_field,
|
|
65
|
-
:text => :
|
|
67
|
+
:text => :plain_text,
|
|
66
68
|
:integer => :text_field,
|
|
67
69
|
:float => :text_field,
|
|
68
70
|
:decimal => :text_field,
|
|
@@ -93,6 +95,45 @@ module InlineForms
|
|
|
93
95
|
:associated => :no_migration
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
PLAIN_TEXT_FORM_ELEMENTS = %i[
|
|
99
|
+
plain_text
|
|
100
|
+
plain_text_area
|
|
101
|
+
text_area_without_ckeditor
|
|
102
|
+
].freeze
|
|
103
|
+
|
|
104
|
+
def self.plain_text_form_element?(form_element)
|
|
105
|
+
PLAIN_TEXT_FORM_ELEMENTS.include?(form_element.to_sym)
|
|
106
|
+
rescue NoMethodError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.assert_plain_text_column!(object:, attribute:, form_element:)
|
|
111
|
+
return unless plain_text_form_element?(form_element)
|
|
112
|
+
return if object.class.column_names.include?(attribute.to_s)
|
|
113
|
+
|
|
114
|
+
raise PlainTextColumnMissingError,
|
|
115
|
+
"#{object.class.name}##{attribute} uses #{form_element} but has no DB column `#{attribute}`. " \
|
|
116
|
+
"Use :rich_text for ActionText-backed attributes, or add a text column for :plain_text."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.validate_plain_text_configuration_for!(klass)
|
|
120
|
+
return unless klass.respond_to?(:table_exists?) &&
|
|
121
|
+
klass.respond_to?(:column_names) &&
|
|
122
|
+
klass.instance_methods.include?(:inline_forms_attribute_list)
|
|
123
|
+
return unless klass.table_exists?
|
|
124
|
+
|
|
125
|
+
attributes = klass.new.inline_forms_attribute_list
|
|
126
|
+
attributes.each do |attribute, _label, form_element|
|
|
127
|
+
next unless plain_text_form_element?(form_element)
|
|
128
|
+
next if klass.column_names.include?(attribute.to_s)
|
|
129
|
+
|
|
130
|
+
raise PlainTextColumnMissingError,
|
|
131
|
+
"#{klass.name} inline_forms_attribute_list declares #{attribute}:#{form_element}, " \
|
|
132
|
+
"but table `#{klass.table_name}` has no `#{attribute}` column. " \
|
|
133
|
+
"Use :rich_text for ActionText-backed attributes."
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
96
137
|
# RELATIONS defines a mapping between AR::Migrations columns and the Model.
|
|
97
138
|
#
|
|
98
139
|
# When a column has the type of :references or :belongs_to, then
|
|
@@ -144,7 +185,6 @@ module InlineForms
|
|
|
144
185
|
inline_forms/inline_forms.css
|
|
145
186
|
inline_forms/devise.css
|
|
146
187
|
inline_forms/inline_forms.js
|
|
147
|
-
inline_forms/ckeditor/config.js
|
|
148
188
|
inline_forms/glass_plate.gif
|
|
149
189
|
)
|
|
150
190
|
end
|
|
@@ -159,6 +199,22 @@ module InlineForms
|
|
|
159
199
|
app.config.assets.paths << path unless app.config.assets.paths.include?(path)
|
|
160
200
|
end
|
|
161
201
|
|
|
202
|
+
config.to_prepare do
|
|
203
|
+
next unless defined?(ActiveRecord::Base)
|
|
204
|
+
|
|
205
|
+
ActiveRecord::Base.descendants.each do |klass|
|
|
206
|
+
begin
|
|
207
|
+
InlineForms.validate_plain_text_configuration_for!(klass)
|
|
208
|
+
rescue InlineForms::PlainTextColumnMissingError
|
|
209
|
+
raise
|
|
210
|
+
rescue StandardError
|
|
211
|
+
# Some descendants might be abstract or temporarily unresolved while
|
|
212
|
+
# the app boots/reloads; runtime checks in controllers still enforce
|
|
213
|
+
# plain_text column presence for active resources.
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
162
218
|
I18n.load_path << Dir[File.join(File.expand_path(File.dirname(__FILE__) + '/locales'), '*.yml')]
|
|
163
219
|
I18n.load_path.flatten!
|
|
164
220
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../example_app/example_integration_test_case"
|
|
4
|
+
|
|
5
|
+
# Stock _show partial: scalar fields are wrapped in <turbo-frame> and use HTML
|
|
6
|
+
# edit/update/cancel (no UJS on field links/forms). Same contract as name_list.
|
|
7
|
+
class ExampleAppApartmentFieldTurboTest < ExampleAppIntegrationTestCase
|
|
8
|
+
setup do
|
|
9
|
+
@apartment = Apartment.find_or_create_by!(name: "Turbo Field Apt") do |a|
|
|
10
|
+
a.title = "Turbo Field Title"
|
|
11
|
+
end
|
|
12
|
+
@frame_id = "apartment_#{@apartment.id}_name"
|
|
13
|
+
@turbo_headers = { "Turbo-Frame" => @frame_id }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test "show panel partial wraps scalar fields in turbo-frame" do
|
|
17
|
+
get apartment_path(@apartment, update: "apartment_#{@apartment.id}"),
|
|
18
|
+
headers: {
|
|
19
|
+
"Accept" => "text/javascript, application/javascript",
|
|
20
|
+
"X-Requested-With" => "XMLHttpRequest"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
assert_response :success
|
|
24
|
+
assert_includes @response.body, "turbo-frame",
|
|
25
|
+
"UJS show.js.erb should embed _show with turbo-frame field wrappers"
|
|
26
|
+
assert_includes @response.body, @frame_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
test "stock scalar field edit update and cancel via turbo-frame" do
|
|
30
|
+
get edit_apartment_path(
|
|
31
|
+
@apartment,
|
|
32
|
+
attribute: "name",
|
|
33
|
+
form_element: "text_field",
|
|
34
|
+
update: @frame_id
|
|
35
|
+
), headers: @turbo_headers
|
|
36
|
+
assert_response :success
|
|
37
|
+
assert_includes @response.body, %(<turbo-frame id="#{@frame_id}">)
|
|
38
|
+
refute_includes @response.body, 'data-remote="true"'
|
|
39
|
+
|
|
40
|
+
put apartment_path(
|
|
41
|
+
@apartment,
|
|
42
|
+
attribute: "name",
|
|
43
|
+
form_element: "text_field",
|
|
44
|
+
update: @frame_id
|
|
45
|
+
), params: { name: "Stock Turbo Name" }, headers: @turbo_headers
|
|
46
|
+
assert_response :success
|
|
47
|
+
assert_includes @response.body, "Stock Turbo Name"
|
|
48
|
+
assert_equal "Stock Turbo Name", @apartment.reload.name
|
|
49
|
+
|
|
50
|
+
get apartment_path(
|
|
51
|
+
@apartment,
|
|
52
|
+
attribute: "name",
|
|
53
|
+
form_element: "text_field",
|
|
54
|
+
update: @frame_id
|
|
55
|
+
), headers: @turbo_headers
|
|
56
|
+
assert_response :success
|
|
57
|
+
assert_includes @response.body, "Stock Turbo Name"
|
|
58
|
+
refute_includes @response.body, 'name="name"',
|
|
59
|
+
"cancel returns read-only field, not edit form"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
test "field show cancel responds to html even without Turbo-Frame header" do
|
|
63
|
+
get apartment_path(
|
|
64
|
+
@apartment,
|
|
65
|
+
attribute: "name",
|
|
66
|
+
form_element: "text_field",
|
|
67
|
+
update: @frame_id
|
|
68
|
+
), headers: { "Accept" => "text/html, application/xhtml+xml" }
|
|
69
|
+
|
|
70
|
+
assert_response :success
|
|
71
|
+
assert_includes @response.body, %(<turbo-frame id="#{@frame_id}">)
|
|
72
|
+
assert_includes @response.body, @apartment.name
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../example_app/example_integration_test_case"
|
|
4
|
+
|
|
5
|
+
# Custom page demo: same turbo-field contract as stock _show, without opening the panel.
|
|
6
|
+
class ExampleAppApartmentNameListTest < ExampleAppIntegrationTestCase
|
|
7
|
+
setup do
|
|
8
|
+
@apartments = 10.times.map do |i|
|
|
9
|
+
Apartment.find_or_create_by!(name: "NameList Apt #{i}") do |a|
|
|
10
|
+
a.title = "Title #{i}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
test "name list page is reachable and renders turbo-frame inline-edit targets for first 10 apartments" do
|
|
16
|
+
get apartment_name_list_path
|
|
17
|
+
assert_response :success
|
|
18
|
+
|
|
19
|
+
Apartment.order(:id).limit(10).each do |apartment|
|
|
20
|
+
assert_includes @response.body, %(<turbo-frame id="apartment_#{apartment.id}_name">),
|
|
21
|
+
"expected turbo-frame wrapper for apartment #{apartment.id}"
|
|
22
|
+
assert_includes @response.body, apartment.name,
|
|
23
|
+
"expected apartment name on page"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
test "more menu links to apartment name list" do
|
|
28
|
+
get apartments_path
|
|
29
|
+
assert_response :success
|
|
30
|
+
assert_includes @response.body, apartment_name_list_path,
|
|
31
|
+
"expected More menu link to name list"
|
|
32
|
+
assert_includes @response.body, "Apartment names (first 10)"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test "name list field links use turbo-frame navigation not UJS remote" do
|
|
36
|
+
apartment = @apartments.first
|
|
37
|
+
get apartment_name_list_path
|
|
38
|
+
assert_response :success
|
|
39
|
+
|
|
40
|
+
frame_html = @response.body[%r{<turbo-frame id="apartment_#{apartment.id}_name">.*?</turbo-frame>}m]
|
|
41
|
+
assert frame_html, "expected turbo-frame for apartment #{apartment.id}"
|
|
42
|
+
assert_match(
|
|
43
|
+
%r/href="[^"]*\/apartments\/#{apartment.id}\/edit[^"]*update=apartment_#{apartment.id}_name[^"]*"/,
|
|
44
|
+
frame_html,
|
|
45
|
+
"expected edit link with update= matching turbo-frame id"
|
|
46
|
+
)
|
|
47
|
+
refute_includes frame_html, 'data-remote="true"',
|
|
48
|
+
"field edits use Turbo frames, not UJS"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
test "name list reuses stock turbo field edit update cycle" do
|
|
52
|
+
apartment = @apartments.first
|
|
53
|
+
frame_id = "apartment_#{apartment.id}_name"
|
|
54
|
+
turbo_headers = { "Turbo-Frame" => frame_id }
|
|
55
|
+
|
|
56
|
+
get edit_apartment_path(
|
|
57
|
+
apartment,
|
|
58
|
+
attribute: "name",
|
|
59
|
+
form_element: "text_field",
|
|
60
|
+
update: frame_id
|
|
61
|
+
), headers: turbo_headers
|
|
62
|
+
assert_response :success
|
|
63
|
+
|
|
64
|
+
put apartment_path(
|
|
65
|
+
apartment,
|
|
66
|
+
attribute: "name",
|
|
67
|
+
form_element: "text_field",
|
|
68
|
+
update: frame_id
|
|
69
|
+
), params: { name: "Name List Turbo" }, headers: turbo_headers
|
|
70
|
+
assert_response :success
|
|
71
|
+
assert_equal "Name List Turbo", apartment.reload.name
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -197,7 +197,7 @@ class ExampleAppApartmentPhotosPaginationTest < ExampleAppIntegrationTestCase
|
|
|
197
197
|
)
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
-
test "
|
|
200
|
+
test "nested photo rows are turbo-framed with Turbo presentation links (no data-turbo false on row)" do
|
|
201
201
|
get photos_path(
|
|
202
202
|
parent_class: "Apartment",
|
|
203
203
|
parent_id: @apartment.id,
|
|
@@ -206,23 +206,207 @@ class ExampleAppApartmentPhotosPaginationTest < ExampleAppIntegrationTestCase
|
|
|
206
206
|
)
|
|
207
207
|
assert_response :success
|
|
208
208
|
|
|
209
|
-
# The container-level opt-out is what protects the inline-edit /
|
|
210
|
-
# replace-photo flow: when a user replaces a photo, show.js.erb /
|
|
211
|
-
# edit.js.erb does $('#<row_id>').html(<form>), so the multipart
|
|
212
|
-
# form that submits the upload is a descendant of this row. With
|
|
213
|
-
# data-turbo="false" on the row, Turbo doesn't intercept that
|
|
214
|
-
# submission -- jquery-ujs + remotipart do, the request goes out
|
|
215
|
-
# with Accept: text/javascript, and the controller's format.js
|
|
216
|
-
# branch handles it. Without this, Turbo Frames would send
|
|
217
|
-
# Accept: text/html and the update action (no format.html for
|
|
218
|
-
# not_accessible_through_html? models like Photo) raises
|
|
219
|
-
# UnknownFormat AFTER the DB write -- a 406 with a corrupted UI.
|
|
220
209
|
sample_row_id = "apartment_#{@apartment.id}_photo_#{@apartment.photos.first.id}"
|
|
221
210
|
assert_match(
|
|
222
|
-
%r{<
|
|
211
|
+
%r{<turbo-frame[^>]*\bid="#{Regexp.escape(sample_row_id)}"},
|
|
223
212
|
@response.body,
|
|
224
|
-
"expected
|
|
225
|
-
"so swapped-in UJS forms inherit the Turbo opt-out"
|
|
213
|
+
"expected each nested photo row to be a <turbo-frame id=\"#{sample_row_id}\">"
|
|
226
214
|
)
|
|
215
|
+
refute_match(
|
|
216
|
+
%r{<turbo-frame[^>]*id="#{Regexp.escape(sample_row_id)}"[^>]*data-turbo="false"},
|
|
217
|
+
@response.body,
|
|
218
|
+
"nested turbo-frame rows must not opt out of Turbo (field cancel / pagination live inside frames)"
|
|
219
|
+
)
|
|
220
|
+
assert_select %(turbo-frame##{sample_row_id} a[data-turbo='true'][data-turbo-frame='#{sample_row_id}']), minimum: 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
test "nested Photo row opens and closes via Turbo HTML (not_accessible_through_html model)" do
|
|
224
|
+
photo = @apartment.photos.first!
|
|
225
|
+
row_id = "apartment_#{@apartment.id}_photo_#{photo.id}"
|
|
226
|
+
row_headers = { "Turbo-Frame" => row_id, "Accept" => "text/html" }
|
|
227
|
+
|
|
228
|
+
get photo_path(photo, update: row_id), headers: row_headers
|
|
229
|
+
assert_response :success
|
|
230
|
+
assert_includes @response.body, %(<turbo-frame id="#{row_id}">)
|
|
231
|
+
assert_includes @response.body, "object_presentation"
|
|
232
|
+
|
|
233
|
+
get photo_path(photo, update: row_id, close: true), headers: row_headers
|
|
234
|
+
assert_response :success
|
|
235
|
+
assert_includes @response.body, %(<turbo-frame id="#{row_id}">)
|
|
236
|
+
refute_includes @response.body, "object_presentation"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
test "nested Photo name field cancel returns field show HTML (Turbo)" do
|
|
240
|
+
photo = @apartment.photos.first!
|
|
241
|
+
frame_id = "apartment_#{@apartment.id}_photo_#{photo.id}_name"
|
|
242
|
+
turbo_headers = { "Turbo-Frame" => frame_id, "Accept" => "text/html" }
|
|
243
|
+
|
|
244
|
+
get edit_photo_path(
|
|
245
|
+
photo,
|
|
246
|
+
attribute: "name",
|
|
247
|
+
form_element: "text_field",
|
|
248
|
+
update: frame_id
|
|
249
|
+
), headers: turbo_headers
|
|
250
|
+
assert_response :success
|
|
251
|
+
|
|
252
|
+
get photo_path(
|
|
253
|
+
photo,
|
|
254
|
+
attribute: "name",
|
|
255
|
+
form_element: "text_field",
|
|
256
|
+
update: frame_id
|
|
257
|
+
), headers: turbo_headers
|
|
258
|
+
assert_response :success
|
|
259
|
+
assert_includes @response.body, %(<turbo-frame id="#{frame_id}">)
|
|
260
|
+
refute_includes @response.body, 'name="name"',
|
|
261
|
+
"cancel must return read-only field, not the edit form"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Step 3 (ujs-to-turbo.md): nested `not_accessible_through_html?` Photo + CarrierWave
|
|
265
|
+
# `image` must accept Turbo-driven multipart PUT inside the field `<turbo-frame>`
|
|
266
|
+
# (no `UnknownFormat` / 406 after DB write — regression class from 7.2.0).
|
|
267
|
+
test "nested Photo image field updates via Turbo multipart PUT inside field frame" do
|
|
268
|
+
photo = @apartment.photos.first!
|
|
269
|
+
frame_id = "apartment_#{@apartment.id}_photo_#{photo.id}_image"
|
|
270
|
+
turbo_headers = { "Turbo-Frame" => frame_id, "Accept" => "text/html" }
|
|
271
|
+
|
|
272
|
+
get edit_photo_path(
|
|
273
|
+
photo,
|
|
274
|
+
attribute: "image",
|
|
275
|
+
form_element: "image_field",
|
|
276
|
+
update: frame_id
|
|
277
|
+
), headers: turbo_headers
|
|
278
|
+
assert_response :success
|
|
279
|
+
assert_includes @response.body, %(enctype="multipart/form-data"),
|
|
280
|
+
"image edit form must stay multipart when Turbo omits remote: true"
|
|
281
|
+
assert_includes @response.body, %(<turbo-frame id="#{frame_id}">)
|
|
282
|
+
|
|
283
|
+
seed_dir = Rails.root.join("db", "seed_images")
|
|
284
|
+
jpgs = Dir.glob(seed_dir.join("*.{jpg,jpeg}"), File::FNM_CASEFOLD).sort
|
|
285
|
+
assert_operator jpgs.size, :>=, 2,
|
|
286
|
+
"need at least two seed jpgs so replacement can differ from current mount"
|
|
287
|
+
|
|
288
|
+
replacement = jpgs.find { |abs| File.basename(abs) != photo.name } || jpgs.last
|
|
289
|
+
uploaded = Rack::Test::UploadedFile.new(replacement, "image/jpeg")
|
|
290
|
+
|
|
291
|
+
put photo_path(
|
|
292
|
+
photo,
|
|
293
|
+
attribute: "image",
|
|
294
|
+
form_element: "image_field",
|
|
295
|
+
update: frame_id
|
|
296
|
+
),
|
|
297
|
+
params: { image: uploaded },
|
|
298
|
+
headers: turbo_headers
|
|
299
|
+
|
|
300
|
+
assert_response :success,
|
|
301
|
+
"multipart image update must respond with HTML (not 406 UnknownFormat)"
|
|
302
|
+
assert_includes @response.body, %(<turbo-frame id="#{frame_id}">)
|
|
303
|
+
refute_match(/UnknownFormat|406/, @response.body)
|
|
304
|
+
|
|
305
|
+
photo.reload
|
|
306
|
+
assert photo.image.present?, "expected CarrierWave mount after Turbo multipart PUT"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# 7.5.2 regression: after cancel / update on a field, the swapped
|
|
310
|
+
# `<turbo-frame id="…">` must contain a Turbo link
|
|
311
|
+
# (`data-turbo="true" data-turbo-frame="_self"`) so the user can re-open
|
|
312
|
+
# the editor. 7.5.1 emitted `data-remote="true"`, which jquery_ujs
|
|
313
|
+
# intercepts as a JS request the controller does not register, so the
|
|
314
|
+
# second click silently fails (no swap, no edit form).
|
|
315
|
+
test "nested Photo image field show after update has Turbo (not data-remote) link" do
|
|
316
|
+
photo = @apartment.photos.first!
|
|
317
|
+
frame_id = "apartment_#{@apartment.id}_photo_#{photo.id}_image"
|
|
318
|
+
turbo_headers = { "Turbo-Frame" => frame_id, "Accept" => "text/html" }
|
|
319
|
+
|
|
320
|
+
seed_dir = Rails.root.join("db", "seed_images")
|
|
321
|
+
jpgs = Dir.glob(seed_dir.join("*.{jpg,jpeg}"), File::FNM_CASEFOLD).sort
|
|
322
|
+
replacement = jpgs.find { |abs| File.basename(abs) != photo.name } || jpgs.last
|
|
323
|
+
uploaded = Rack::Test::UploadedFile.new(replacement, "image/jpeg")
|
|
324
|
+
|
|
325
|
+
put photo_path(
|
|
326
|
+
photo,
|
|
327
|
+
attribute: "image",
|
|
328
|
+
form_element: "image_field",
|
|
329
|
+
update: frame_id
|
|
330
|
+
),
|
|
331
|
+
params: { image: uploaded },
|
|
332
|
+
headers: turbo_headers
|
|
333
|
+
assert_response :success
|
|
334
|
+
refute_match(
|
|
335
|
+
/data-remote="true"/,
|
|
336
|
+
@response.body,
|
|
337
|
+
"field_show after Turbo update must use Turbo data attributes; " \
|
|
338
|
+
"data-remote=\"true\" hits jquery_ujs (no JS responder) and the " \
|
|
339
|
+
"second click silently fails."
|
|
340
|
+
)
|
|
341
|
+
assert_match(
|
|
342
|
+
/data-turbo="true"/,
|
|
343
|
+
@response.body,
|
|
344
|
+
"expected Turbo data attribute on the inline-edit link inside the swapped field frame"
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Same regression on the cancel path (no DB write): clicking the field
|
|
349
|
+
# cancel returns the read-only field; the link inside must be a Turbo link
|
|
350
|
+
# so the user can re-open the editor.
|
|
351
|
+
test "nested Photo image field show after cancel has Turbo (not data-remote) link" do
|
|
352
|
+
photo = @apartment.photos.first!
|
|
353
|
+
frame_id = "apartment_#{@apartment.id}_photo_#{photo.id}_image"
|
|
354
|
+
turbo_headers = { "Turbo-Frame" => frame_id, "Accept" => "text/html" }
|
|
355
|
+
|
|
356
|
+
get photo_path(
|
|
357
|
+
photo,
|
|
358
|
+
attribute: "image",
|
|
359
|
+
form_element: "image_field",
|
|
360
|
+
update: frame_id
|
|
361
|
+
), headers: turbo_headers
|
|
362
|
+
assert_response :success
|
|
363
|
+
refute_match(/data-remote="true"/, @response.body,
|
|
364
|
+
"cancel-side field_show must not regress to UJS data-remote=\"true\"")
|
|
365
|
+
assert_match(/data-turbo="true"/, @response.body)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
test "nested Photo new cancel and create via Turbo inside associated list frame" do
|
|
369
|
+
frame = "apartment_#{@apartment.id}_photos"
|
|
370
|
+
headers = { "Turbo-Frame" => frame, "Accept" => "text/html" }
|
|
371
|
+
|
|
372
|
+
get new_photo_path(update: frame, parent_class: "Apartment", parent_id: @apartment.id),
|
|
373
|
+
headers: headers
|
|
374
|
+
assert_response :success
|
|
375
|
+
assert_includes @response.body, %(<turbo-frame id="#{frame}">)
|
|
376
|
+
assert_includes @response.body, "stylesheet", "new form must use inline_forms layout (styled)"
|
|
377
|
+
assert_includes @response.body, %(enctype="multipart/form-data")
|
|
378
|
+
assert_includes @response.body, 'class="edit_form"'
|
|
379
|
+
assert_includes @response.body, 'name="name"'
|
|
380
|
+
|
|
381
|
+
get photos_path(
|
|
382
|
+
parent_class: "Apartment",
|
|
383
|
+
parent_id: @apartment.id,
|
|
384
|
+
update: frame,
|
|
385
|
+
ul_needed: true
|
|
386
|
+
), headers: headers
|
|
387
|
+
assert_response :success
|
|
388
|
+
assert_match %r{<turbo-frame id="#{frame}"}, @response.body
|
|
389
|
+
assert_match %r{<turbo-frame id="#{@update_span}"}, @response.body
|
|
390
|
+
|
|
391
|
+
seed = Rails.root.join("db/seed_images/dsc00099.jpg")
|
|
392
|
+
uploaded = Rack::Test::UploadedFile.new(seed, "image/jpeg")
|
|
393
|
+
|
|
394
|
+
assert_difference("Photo.count", 1) do
|
|
395
|
+
post photos_path(
|
|
396
|
+
update: frame,
|
|
397
|
+
parent_class: "Apartment",
|
|
398
|
+
parent_id: @apartment.id
|
|
399
|
+
),
|
|
400
|
+
params: {
|
|
401
|
+
name: "curl_new_photo.jpg",
|
|
402
|
+
caption: "from turbo test",
|
|
403
|
+
image: uploaded
|
|
404
|
+
},
|
|
405
|
+
headers: headers
|
|
406
|
+
end
|
|
407
|
+
assert_response :success
|
|
408
|
+
assert_match %r{<turbo-frame id="#{frame}"}, @response.body
|
|
409
|
+
assert_match %r{<turbo-frame id="#{@update_span}"}, @response.body
|
|
410
|
+
assert_includes @response.body, "curl_new_photo.jpg"
|
|
227
411
|
end
|
|
228
412
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../example_app/example_integration_test_case"
|
|
4
|
+
|
|
5
|
+
# Stock `/apartments` index: each row is `<turbo-frame id="apartment_<id>">`;
|
|
6
|
+
# opening/closing the inline panel uses Turbo GET `show` + `close` (HTML), not UJS.
|
|
7
|
+
class ExampleAppApartmentRowTurboTest < ExampleAppIntegrationTestCase
|
|
8
|
+
setup do
|
|
9
|
+
@apartment = Apartment.first || Apartment.create!(name: "Row Turbo Apt", title: "T")
|
|
10
|
+
@row_frame = "apartment_#{@apartment.id}"
|
|
11
|
+
@row_headers = { "Turbo-Frame" => @row_frame, "Accept" => "text/html" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
test "apartments index wraps each row in a turbo-frame" do
|
|
15
|
+
get apartments_path
|
|
16
|
+
assert_response :success
|
|
17
|
+
assert_includes @response.body, %(<turbo-frame id="#{@row_frame}">),
|
|
18
|
+
"expected top-level list row inside turbo-frame"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test "row presentation link uses turbo navigation not UJS remote" do
|
|
22
|
+
get apartments_path
|
|
23
|
+
assert_response :success
|
|
24
|
+
assert_select "turbo-frame##{@row_frame} div.small-11.column > a", count: 1 do |elements|
|
|
25
|
+
el = elements.first
|
|
26
|
+
assert_equal "true", el["data-turbo"]
|
|
27
|
+
assert_equal @row_frame, el["data-turbo-frame"]
|
|
28
|
+
assert_nil el["data-remote"]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "row open show returns full panel inside matching turbo-frame" do
|
|
33
|
+
get apartment_path(@apartment, update: @row_frame), headers: @row_headers
|
|
34
|
+
assert_response :success
|
|
35
|
+
assert_includes @response.body, %(<turbo-frame id="#{@row_frame}">)
|
|
36
|
+
assert_includes @response.body, "object_presentation",
|
|
37
|
+
"expected expanded _show panel"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test "row close returns collapsed row inside matching turbo-frame" do
|
|
41
|
+
get apartment_path(@apartment, update: @row_frame, close: true), headers: @row_headers
|
|
42
|
+
assert_response :success
|
|
43
|
+
assert_includes @response.body, %(<turbo-frame id="#{@row_frame}">)
|
|
44
|
+
refute_includes @response.body, "object_presentation",
|
|
45
|
+
"expected _close row, not full panel"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
test "row close responds to html without Turbo-Frame header" do
|
|
49
|
+
get apartment_path(
|
|
50
|
+
@apartment,
|
|
51
|
+
update: @row_frame,
|
|
52
|
+
close: true
|
|
53
|
+
), headers: { "Accept" => "text/html, application/xhtml+xml" }
|
|
54
|
+
|
|
55
|
+
assert_response :success
|
|
56
|
+
assert_includes @response.body, %(<turbo-frame id="#{@row_frame}">)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
test "row toolbar trash and destroy links use Turbo not UJS remote" do
|
|
60
|
+
get apartments_path
|
|
61
|
+
assert_response :success
|
|
62
|
+
assert_select "turbo-frame##{@row_frame} a[data-turbo='true'][data-turbo-frame='#{@row_frame}']", minimum: 1
|
|
63
|
+
refute_select "turbo-frame##{@row_frame} a[data-remote='true']"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
test "destroy via Turbo DELETE returns undo inside matching turbo-frame" do
|
|
67
|
+
doomed = Apartment.create!(name: "Turbo Destroy Me", title: "X")
|
|
68
|
+
frame = "apartment_#{doomed.id}"
|
|
69
|
+
headers = { "Turbo-Frame" => frame, "Accept" => "text/html" }
|
|
70
|
+
|
|
71
|
+
delete apartment_path(doomed, update: frame), headers: headers
|
|
72
|
+
assert_response :success
|
|
73
|
+
assert_includes @response.body, %(<turbo-frame id="#{frame}">)
|
|
74
|
+
assert_includes @response.body, "undo"
|
|
75
|
+
assert_not Apartment.exists?(doomed.id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
test "revert via Turbo POST restores row as collapsed turbo-frame" do
|
|
79
|
+
doomed = Apartment.create!(name: "Turbo Revert Me", title: "Y")
|
|
80
|
+
apt_id = doomed.id
|
|
81
|
+
frame = "apartment_#{apt_id}"
|
|
82
|
+
headers = { "Turbo-Frame" => frame, "Accept" => "text/html" }
|
|
83
|
+
|
|
84
|
+
delete apartment_path(doomed, update: frame), headers: headers
|
|
85
|
+
assert_response :success
|
|
86
|
+
|
|
87
|
+
destroy_version = PaperTrail::Version.where(item_type: "Apartment", item_id: apt_id).order(:id).last
|
|
88
|
+
post revert_apartment_path(destroy_version.id, update: frame), headers: headers
|
|
89
|
+
assert_response :success
|
|
90
|
+
assert Apartment.where(name: "Turbo Revert Me").exists?
|
|
91
|
+
assert_includes @response.body, %(<turbo-frame id="#{frame}">)
|
|
92
|
+
refute_includes @response.body, "object_presentation"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../example_app/example_integration_test_case"
|
|
4
|
+
|
|
5
|
+
# 7.5.2: top-level Apartment +new+ / +cancel+ / +create+ keep the legacy UJS
|
|
6
|
+
# (+remote: true+) contract. 7.5.1 emitted +data-turbo-frame="apartments_list"+
|
|
7
|
+
# on the +"+ new"+ link, but the index page wraps the list in a plain
|
|
8
|
+
# +<div id="apartments_list">+ (not a +<turbo-frame>+) -- so:
|
|
9
|
+
#
|
|
10
|
+
# * +cancel+ / +create+ targeted a frame the page did not have, and Turbo
|
|
11
|
+
# logged "Content missing" and dropped the response;
|
|
12
|
+
# * the +"+ new"+ click itself either fell back to a full-page navigation or
|
|
13
|
+
# landed in that broken state.
|
|
14
|
+
#
|
|
15
|
+
# Wrapping the top-level list in a real +<turbo-frame>+ regresses layout
|
|
16
|
+
# (the frame collapses inside +position: absolute+ +#outer_container+), so
|
|
17
|
+
# the fix keeps top-level behind UJS: +link_to_new_record+ omits Turbo data
|
|
18
|
+
# attributes when no +parent_class+ is provided, and +new.js.erb+ /
|
|
19
|
+
# +list.js.erb+ swap +#apartments_list+ contents in place.
|
|
20
|
+
class ExampleAppApartmentTopLevelNewTest < ExampleAppIntegrationTestCase
|
|
21
|
+
setup do
|
|
22
|
+
@frame = "apartments_list"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
test "top-level apartments index keeps the legacy <div> wrapper (no <turbo-frame> for the list root)" do
|
|
26
|
+
get apartments_path
|
|
27
|
+
assert_response :success
|
|
28
|
+
assert_match(
|
|
29
|
+
%r{<div[^>]+class="list_container"[^>]+id="#{Regexp.escape(@frame)}"},
|
|
30
|
+
@response.body,
|
|
31
|
+
"top-level list must stay a <div id=\"apartments_list\"> for UJS swaps; " \
|
|
32
|
+
"wrapping in a <turbo-frame> at this position breaks layout"
|
|
33
|
+
)
|
|
34
|
+
refute_match(
|
|
35
|
+
%r{<turbo-frame\s+id="#{Regexp.escape(@frame)}"},
|
|
36
|
+
@response.body,
|
|
37
|
+
"top-level list root should NOT be a <turbo-frame> -- doing so regresses " \
|
|
38
|
+
"layout (frame collapses under fixed top bars) and orphans cancel/create"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
test "top-level + new link uses UJS (data-remote), not Turbo" do
|
|
43
|
+
get apartments_path
|
|
44
|
+
assert_response :success
|
|
45
|
+
assert_match(
|
|
46
|
+
%r{<a [^>]*class="button new_button"[^>]*data-remote="true"[^>]*href="/apartments/new\?update=apartments_list"},
|
|
47
|
+
@response.body,
|
|
48
|
+
"top-level + must POST via UJS (no <turbo-frame> on the page to target); " \
|
|
49
|
+
"data-turbo* on this link causes 'Content missing' on cancel/create"
|
|
50
|
+
)
|
|
51
|
+
refute_match(
|
|
52
|
+
%r{<a [^>]*class="button new_button"[^>]*data-turbo-frame="#{Regexp.escape(@frame)}"},
|
|
53
|
+
@response.body,
|
|
54
|
+
"top-level + must NOT carry data-turbo-frame=apartments_list (no matching frame)"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test "top-level new returns JS that swaps #apartments_list with the form (UJS)" do
|
|
59
|
+
get new_apartment_path(update: @frame), xhr: true
|
|
60
|
+
assert_response :success
|
|
61
|
+
assert_includes @response.content_type, "javascript",
|
|
62
|
+
"UJS XHR must hit format.js"
|
|
63
|
+
assert_match(
|
|
64
|
+
%r{\$\('#apartments_list'\)\.html\(},
|
|
65
|
+
@response.body,
|
|
66
|
+
"UJS new.js.erb must swap #apartments_list with the rendered form"
|
|
67
|
+
)
|
|
68
|
+
assert_includes @response.body, 'name=\"name\"'
|
|
69
|
+
assert_includes @response.body, 'class=\"edit_form\"'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
test "top-level cancel returns JS that swaps #apartments_list back to the list (UJS)" do
|
|
73
|
+
get apartments_path(update: @frame, ul_needed: true), xhr: true
|
|
74
|
+
assert_response :success
|
|
75
|
+
assert_includes @response.content_type, "javascript",
|
|
76
|
+
"UJS XHR must hit format.js"
|
|
77
|
+
assert_match(
|
|
78
|
+
%r{\$\('#apartments_list'\)\.html\(},
|
|
79
|
+
@response.body,
|
|
80
|
+
"UJS list.js.erb must swap #apartments_list back to the list"
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
test "top-level create via UJS persists and returns the list swap" do
|
|
85
|
+
name = "TopLevelNewApt-#{SecureRandom.hex(4)}"
|
|
86
|
+
assert_difference("Apartment.count", 1) do
|
|
87
|
+
post apartments_path(update: @frame),
|
|
88
|
+
params: { name: name, title: "Top level new test" },
|
|
89
|
+
xhr: true
|
|
90
|
+
end
|
|
91
|
+
assert_response :success
|
|
92
|
+
assert_includes @response.content_type, "javascript"
|
|
93
|
+
assert_match(
|
|
94
|
+
%r{\$\('#apartments_list'\)\.html\(},
|
|
95
|
+
@response.body
|
|
96
|
+
)
|
|
97
|
+
assert_includes @response.body, name,
|
|
98
|
+
"expected the newly-created Apartment to appear in the swapped list"
|
|
99
|
+
end
|
|
100
|
+
end
|