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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -0
  3. data/README.rdoc +4 -2
  4. data/app/assets/javascripts/inline_forms/inline_forms.js +23 -0
  5. data/app/assets/stylesheets/inline_forms/inline_forms.scss +32 -16
  6. data/app/controllers/concerns/versions_concern.rb +2 -1
  7. data/app/controllers/inline_forms_application_controller.rb +5 -1
  8. data/app/controllers/inline_forms_controller.rb +120 -24
  9. data/app/helpers/form_elements/ckeditor.rb +4 -30
  10. data/app/helpers/form_elements/plain_text.rb +23 -0
  11. data/app/helpers/form_elements/plain_text_area.rb +7 -3
  12. data/app/helpers/form_elements/text_area.rb +4 -44
  13. data/app/helpers/form_elements/text_area_without_ckeditor.rb +5 -4
  14. data/app/helpers/form_elements/text_field.rb +2 -2
  15. data/app/helpers/inline_forms_helper.rb +127 -71
  16. data/app/views/devise/sessions/_form.html.erb +4 -1
  17. data/app/views/inline_forms/_close.html.erb +6 -4
  18. data/app/views/inline_forms/_edit.html.erb +7 -40
  19. data/app/views/inline_forms/_list.html.erb +52 -39
  20. data/app/views/inline_forms/_new.html.erb +23 -11
  21. data/app/views/inline_forms/_show.html.erb +13 -11
  22. data/app/views/inline_forms/_versions_list.html.erb +4 -8
  23. data/app/views/inline_forms/create_list_frame.html.erb +3 -0
  24. data/app/views/inline_forms/field_edit.html.erb +3 -0
  25. data/app/views/inline_forms/field_show.html.erb +3 -0
  26. data/app/views/inline_forms/new_record.html.erb +3 -0
  27. data/app/views/inline_forms/row_close.html.erb +5 -0
  28. data/app/views/inline_forms/row_destroyed.html.erb +9 -0
  29. data/app/views/inline_forms/row_show.html.erb +3 -0
  30. data/app/views/inline_forms/versions_list_panel.html.erb +3 -0
  31. data/app/views/inline_forms/versions_panel.html.erb +3 -0
  32. data/app/views/layouts/application.html.erb +0 -1
  33. data/app/views/layouts/inline_forms.html.erb +10 -1
  34. data/bin/inline_forms +22 -1
  35. data/bin/inline_forms_installer_core.rb +38 -3
  36. data/docs/ujs-to-turbo.md +193 -0
  37. data/lib/generators/USAGE +2 -2
  38. data/lib/generators/assets/stylesheets/inline_forms.scss +32 -16
  39. data/lib/inline_forms/version.rb +1 -1
  40. data/lib/inline_forms.rb +58 -2
  41. data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_field_turbo_test.rb +74 -0
  42. data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_name_list_test.rb +73 -0
  43. data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_photos_pagination_test.rb +199 -15
  44. data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_row_turbo_test.rb +94 -0
  45. data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_top_level_new_test.rb +100 -0
  46. data/lib/installer_templates/example_app_tests/test/integration/example_app_apartment_versions_turbo_test.rb +27 -0
  47. data/lib/installer_templates/example_app_tests/test/models/example_app_plain_text_rich_text_edge_cases_test.rb +46 -0
  48. data/lib/installer_templates/example_app_views/apartments/name_list.html.erb +26 -0
  49. data/lib/installer_templates/example_app_views/inline_forms/_header.html.erb +45 -0
  50. data/test/inline_forms_generator_test.rb +10 -0
  51. data/test/plain_text_configuration_test.rb +90 -0
  52. metadata +21 -5
  53. data/app/views/inline_forms/edit.js.erb +0 -1
  54. data/app/views/inline_forms/show_element.js.erb +0 -1
  55. data/app/views/inline_forms/update.js.erb +0 -1
  56. 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 => :text_area,
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 "row container opts out of Turbo so swapped-in UJS forms (replace photo etc.) keep working" do
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{<div[^>]+id="#{Regexp.escape(sample_row_id)}"[^>]*data-turbo="false"|<div[^>]+data-turbo="false"[^>]*id="#{Regexp.escape(sample_row_id)}"},
211
+ %r{<turbo-frame[^>]*\bid="#{Regexp.escape(sample_row_id)}"},
223
212
  @response.body,
224
- "expected the per-row container (id=\"#{sample_row_id}\") to carry data-turbo=\"false\" " \
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